导航菜单

  • 1.什么是MCP
  • 2.MCP架构
  • 3.MCP服务器
  • 4.MCP客户端
  • 5.版本控制
  • 6.连接MCP服务器
  • 7.SDKs
  • 8.Inspector
  • 9.规范
  • 10.架构
  • 11.协议
  • 12.生命周期
  • 13.工具
  • 14.资源
  • 15.提示
  • 16.日志
  • 17.进度
  • 18.传输
  • 19.补全
  • 20.引导
  • 21.采样
  • 22.任务
  • 23.取消
  • 24.Ping
  • 25.根
  • 26.分页
  • 27.授权
  • 28.初始化
  • 29.工具
  • 30.资源
  • 31.结构化输出
  • 32.提示词
  • 33.上下文
  • 34.StreamableHTTP
  • 35.参数补全
  • 36.引导
  • 37.采样
  • 38.LowLevel
  • 39.任务
  • 40.取消
  • 41.ping
  • 42.根
  • 43.分页
  • 44.授权
  • 45.FunctionCalling
  • starlette
  • FastAPI
  • Keycloak
  • asyncio
  • contextlib
  • httpx
  • pathlib
  • pydantic
  • queue
  • subprocess
  • threading
  • uvicorn
  • JSON-RPC
  • LiteLLM
  • pydantic-settings
  • ai_agent
  • format
  • diff
  • mcp_server
  • 1.初始化项目
  • 2. 启动服务器
    • 2.1. init.py
    • 2.2. main.py
    • 2.3. main.py
  • 3. 连接数据库
    • 3.1 创建数据库
    • 3.2. .env
    • 3.3. config.py
    • 3.4. database.py
    • 3.5. main.py
  • 4. 数据模型
    • 4.1. models.py
    • 4.2. 表说明
      • 4.2.1 mcp_services
      • 4.2.2 llm_models
      • 4.2.3 agents
      • 4.2.4 agent_chat_sessions
      • 4.2.5 agent_chat_messages
      • 4.2.6 逻辑关系
    • 4.3. ER 图
    • 4.4. 高层关系
  • 5. 建表
    • 5.1. main.py
    • 5.2. main.py
  • 6. 跨域
    • 6.1. main.py
  • 7. 添加MCP服务
    • 7.1. init.py
    • 7.2. mcp_repository.py
    • 7.3. init.py
    • 7.4. mcp_services.py
    • 7.5. schemas.py
    • 7.6. main.py
    • 7.7 测试
  • 8. 查看MCP服务列表
    • 8.1. mcp_repository.py
    • 8.2. mcp_services.py
    • 8.3. main.py
  • 9. 查看MCP服务
    • 9.1. mcp_repository.py
    • 9.2. mcp_services.py
  • 10. 更新MCP服务
    • 10.1. mcp_repository.py
    • 10.2. mcp_services.py
    • 10.3. schemas.py
    • 10.4 测试
  • 11. 删除MCP服务
    • 11.1. mcp_repository.py
    • 11.2. mcp_services.py
    • 11.3 测试
  • 11. 路线规划服务
    • 11.1. mcp_tester.py
    • 11.2. mcp_httpx.py
    • 11.3. direction-client.py
    • 11.4. direction-server.py
    • 11.5. mcp_services.py
    • 11.6. schemas.py
    • 11.7 测试
    • 11.8 路线规划服务
      • 11.8.1 描述
      • 11.8.2 协议
      • 11.8.3 URL
      • 11.8.4 请求头
    • 11.9 时序图
  • 12. 地点检索服务
    • 12.5 深入集成示例
    • 12.1. place-client.py
    • 12.2. place-server.py
    • 12.3. mcp_tester.py
    • 12.4 测试
    • 12.5 地点检索服务
      • 12.5.1 描述
      • 12.5.2 协议
      • 12.5.3 URL
      • 12.5.4 请求头
    • 12.6 时序图
  • 13. 天气查询服务
    • 13.1. weather-client.py
    • 13.2. weather-server.py
    • 13.3. mcp_tester.py
    • 13.4 测试
    • 13.5 天气查询服务
      • 13.5.1 描述
      • 13.5.2 协议
      • 13.5.3 命令
      • 13.5.4 参数
      • 13.5.5 环境变量
    • 13.6 时序图
  • 14. 添加大模型
    • 14.1. llm_repository.py
    • 14.2. llm_models.py
    • 14.3. uploads.py
    • 14.4. config.py
    • 14.5. main.py
    • 14.6. models.py
    • 14.7. schemas.py
    • 14.8. 测试
    • 14.9. 数据
      • 14.9.1 API 地址
      • 14.9.2 LOGO
      • 14.9.3 API 密钥
      • 14.9.4 获取密钥地址
      • 14.9.5 模型名称
    • 14.10 上传图片时序图
    • 14.11 检测大模型时序图
    • 14.12 添加大模型时序图
  • 15. 大模型列表
    • 15.1. llm_repository.py
    • 15.2. llm_models.py
    • 15.3 测试
  • 16. 更新大语言模型
    • 16.1. llm_repository.py
    • 16.2. llm_models.py
    • 16.3. schemas.py
    • 16.4.测试
  • 17. 删除大语言模型
    • 17.1. llm_repository.py
    • 17.2. llm_models.py
    • 17.3 测试
  • 18. 添加查看智能体
    • 18.1. agent_repository.py
    • 18.2. agents.py
    • 18.3. main.py
    • 18.4. models.py
    • 18.5. schemas.py
    • 18.6 测试
    • 18.7 旅游规划智能体
      • 18.7.1 描述
      • 18.7.2 开场白
      • 18.7.3 系统提示词
      • 18.7.4 提问提示词模板
      • 18.7.5 提问变量配置
  • 19. 修改智能体
    • 19.1. agent_repository.py
    • 19.2. agents.py
    • 19.3. schemas.py
    • 19.4 测试
  • 20. 删除智能体
    • 20.1. agent_repository.py
    • 20.2. agents.py
    • 20.3 测试
  • 21. 创建和删除对话
    • 21.1. agent_chat_repository.py
    • 21.2. agent_chat.py
    • 21.3. main.py
    • 21.4. models.py
    • 21.5. agents.py
    • 21.6. schemas.py
  • 22. 获取对话信息列表
    • 22.1. models.py
    • 22.2. agent_chat_repository.py
    • 22.3. agent_chat.py
    • 22.4. schemas.py
  • 23. 创建对话消息
    • 23.1. agent_chat_repository.py
    • 23.2. agent_chat.py
    • 23.3. schemas.py
  • 24.服务器处理对话请求
    • 24.1. agent_chat.py
    • 24.2. agent_chat_repository.py
    • 24.3. agent_chat.py
    • 24.4. agents.py
    • 24.5. schemas.py
  • 25.重构流式对话结构
    • 25.1. agent_chat.py
    • 25.2. agent_chat_stream.py
    • 25.3. agent_chat_repository.py
    • 25.4. agent_chat.py
    • 25.5 执行流程
      • 25.5.1 整体在做什么
      • 25.5.2 入口:stream_message
      • 25.5.3 同步准备:prepare_stream_chat
      • 25.5.4 SSE 层:iter_agent_chat_sse
      • 25.5.5 MCP 与 build_openai_tools
        • 25.5.5.1 辅助能力
        • 25.5.5.2 名称与 Schema
        • 25.5.5.3 build_openai_tools(services)(核心)
      • 25.5.6 generate_with_tools:设计意图
      • 25.5.7 端到端时序图(含 MCP 工具拉取)
      • 25.5.8 prepare_stream_chat 决策流
      • 25.5.9 build_openai_tools 数据流
      • 25.5.10 模块职责一览
  • 26.调用大模型回答
    • 26.1. agent_chat.py
    • 26.2 执行过程
      • 26.2.1. 函数职责
      • 26.2.2. 逐步执行顺序
      • 26.2.3. 时序图(从调用方到 LLM 再回传)
      • 26.2.4. 与 iter_chat_completion_events 的配合
      • 26.2.5. generate_with_tools流程图
  • 27.调用MCP工具
    • 27.1. agent_chat.py
    • 27.2. agent_chat.py
    • 27.3. agent_chat_stream.py
    • 27.3 执行流程
      • 27.3.1. 总体结构
      • 27.3.2. 准备阶段(循环前)
      • 27.3.3. 单轮 LLM 流式:iter_chat_completion_events 与工具在流里的合并
      • 27.3.4. 工具调用环节
      • 27.3.5. 10 轮用尽后的「最终补全」
      • 27.3.6. 时序图(工具调用一轮)
      • 27.3.7. merge_stream_tool_calls 与 chunk 的关系
      • 27.3.8. 小结表

1.初始化项目 #

本项目基于 FastAPI 搭建,采用 SQLAlchemy 进行 ORM 数据库操作,适用于构建以「智能体」为中心的对话或多模型应用后端。以下为初始化项目与依赖配置的详细说明:

  1. 创建虚拟环境与依赖安装
    推荐使用 uv 工具进行依赖和环境管理,兼容 npm/yarn 的开发体验,大幅加快 Python 依赖管理的效率。

    • 初始化项目目录(生成 pyproject.toml 等):
      uv init
    • 安装所需依赖包:
      uv add python-multipart fastapi uvicorn[standard] sqlalchemy ...
  2. 依赖说明

    • fastapi:主框架,支持异步 RESTful API 和自动生成 OpenAPI 文档,适合敏捷开发。
    • uvicorn[standard]:主流高性能的 ASGI 服务器,用于运行 FastAPI 项目,[standard]字样表示包含优化依赖如 uvloop、httptools 等,提高吞吐量和响应速度。
    • sqlalchemy:行业标准的 Python ORM,统一支持多种数据库,定义模型与数据结构。
    • pymysql:MySQL 适配驱动,配合 SQLAlchemy 实现数据存取。
    • cryptography:用于敏感信息的加解密保障安全。
    • pydantic/pydantic-settings:用于数据模型的类型验证和自动配置环境变量,与 FastAPI 深度集成。
    • httpx:现代化 HTTP 客户端,支持同步与异步请求,适配微服务或第三方 API 调用。
    • python-dotenv:方便本地开发时读取.env环境变量文件。
    • mcp[cli]:多模型协同平台的管理 CLI,可以集中便捷地管理各类 LLM 服务/插件/模型资源。
    • langchain & langchain-deepseek:打造基于大模型(如 DeepSeek、大语言模型等)的复杂推理、记忆和工具调用能力。
  3. 目录结构建议

    ├── app/
    │   ├── __init__.py
    │   ├── main.py      # FastAPI 应用主入口
    │   ├── models.py    # ORM 数据模型定义
    │   └── database.py  # 数据库连接与Session管理
    ├── main.py          # 作为服务器启动入口(可选)
    ├── .env             # 环境变量(开发/部署配置)
    ├── requirements.txt / pyproject.toml
    └── README.md
  4. 开发与启动

    • 初始化数据库表结构可通过 SQLAlchemy 自动生成。
    • 使用如下命令启动开发服务器(默认热重载):
      uvicorn app.main:app --reload
    • 自定义配置(端口、host、日志等)可在 uvicorn.run() 或启动命令中指定。
uv init
uv add  python-multipart fastapi uvicorn[standard] sqlalchemy pymysql cryptography pydantic pydantic-settings httpx python-dotenv mcp[cli] langchain langchain-deepseek
模块名称 作用简介
python-multipart 处理 HTTP 的 multipart/form-data(常用于文件上传)
fastapi 高性能 Web API 框架,支持异步、自动文档生成
uvicorn[standard] ASGI 服务器,运行 FastAPI 等异步 Python 应用,standard 包含性能增强依赖
sqlalchemy Python ORM 框架,简化数据库操作
pymysql MySQL 数据库的 Python 客户端
cryptography 常用的加密和安全功能库
pydantic 数据验证与解析库,基于 Python 类型注解
pydantic-settings 基于 pydantic 的配置管理工具
httpx 新一代 Python HTTP 客户端,支持异步和同步请求
python-dotenv 加载 .env 文件中的环境变量,便于配置管理
mcp[cli] 多模型协同平台的命令行工具,便于模型及资源管理
langchain 用于大语言模型(LLM)应用开发的框架
langchain-deepseek Langchain 的 deepseek 插件支持,便于集成 DeepSeek LLM

2. 启动服务器 #

本项目推荐使用 FastAPI 作为 Web 服务主框架,并通过 Uvicorn 启动应用服务器,配合 SQLAlchemy 实现数据库管理。具体启动流程如下:

  1. 本地开发环境准备
    确保 Python 环境和必需依赖已安装,可以参考前面提供的 uv add ... 安装命令。若有.env配置文件,建议同步环境变量。

  2. 启动 API 服务方式

    • 方法一:直接使用 Uvicorn 命令行
      在项目根目录下运行:

      uvicorn app.main:app --reload --host 0.0.0.0 --port 8000

      其中:

      • app.main:app 指定 FastAPI 实例的位置(app/main.py 里的 app 对象)。
      • --reload 让开发阶段的服务变动代码自动重启(建议开发环境开启,生产关闭)。
      • 可以通过 --port 设置监听端口。
    • 方法二:运行 main.py 脚本
      也可直接运行根目录下的 main.py,其内部会调用 uvicorn.run 启动服务(底层同样使用 Uvicorn)。

      python main.py
  3. 验证服务启动情况
    启动后可访问 http://localhost:8000/docs 查看自动生成的 API 文档;
    访问 http://localhost:8000/health 可验证健康检查接口是否正常响应:

    {
      "status": "ok"
    }
  4. 可选配置

    • 可通过 .env 文件自定义 FastAPI 的标题、描述、数据库地址等参数。
    • 若需绑定公网 IP 或自定义域名,请确保服务器开放相关端口。

注意:

  • 生产部署建议关闭 --reload,并可结合多进程服务(如 Gunicorn 搭配 Uvicorn worker)。
  • 若需对接前端/第三方系统,可提前规划跨域(CORS)、接口鉴权等中间件功能。

由此,可快速搭建起数据驱动、接口友好、可拓展的智能体后端服务。

2.1. init.py #

app/init.py

2.2. main.py #

app/main.py

# 导入FastAPI库
from fastapi import FastAPI

# 创建FastAPI应用实例,设置标题和版本号
app = FastAPI(title="智能体服务", version="0.1.0")

# 定义一个GET类型的/health路由用于健康检查
@app.get("/health")
def health():
    # 返回服务状态为ok的JSON响应
    return {"status": "ok"}

2.3. main.py #

main.py

# 导入uvicorn库,用于运行ASGI服务器
+import uvicorn

# 判断当前模块是否为主模块(直接运行该脚本时为True)
if __name__ == "__main__":
    # 启动uvicorn服务器,加载app.main模块中的app对象
    # host设置为0.0.0.0,允许外部访问
    # port设置为8000
    # reload=True表示代码变动时自动重启服务(开发环境常用)
+   uvicorn.run(
+       "app.main:app",
+       host="0.0.0.0",
+       port=8000,
+       reload=True,
+   )

3. 连接数据库 #

在「连接数据库」这一小节,我们将详细介绍如何在 FastAPI 项目中集成 SQLAlchemy 来操作 MySQL 数据库。

一般流程分为以下几步:

  1. 配置数据库连接
    在 .env 和 app/config.py 中定义数据库连接字符串,便于统一管理和灵活切换。

    • .env 文件通过 DATABASE_URL 配置连接参数,不建议把账号密码硬编码到代码仓库。
    • 在 app/config.py 中通过 pydantic-settings 自动加载 .env 配置,提供便捷的全局访问。
  2. 创建 SQLAlchemy 数据库引擎和会话

    • 在 app/database.py 中定义 engine(连接池)、SessionLocal(数据库会话工厂)和 Base(ORM模型基类)。
    • 所有 ORM 模型需继承自 Base,以便 SQLAlchemy 能自动发现和创建数据表。
  3. 依赖注入数据库会话

    • 在业务路由中,需要为每个请求提供一个独立的数据库会话。通常可通过 FastAPI 的依赖注入机制(Depends)实现。
    • 推荐定义 get_session 函数:用 yield 生成 session,自动实现请求结束时的关闭和资源回收,避免连接泄漏。
  4. 数据库表模型定义与迁移(可选)

    • 以 Python 类的方式定义数据表结构,继承自 Base。
    • 若需要自动迁移/同步表结构,可借助 Alembic 等工具完成。
  5. 安全与性能建议

    • 生产环境下建议使用更复杂的数据库账号密码,并控制连接池数量。
    • 谨慎处理事务,避免长事务与死锁。

通过上述步骤,即可为智能体后端服务建立可靠的数据库访问能力,为后续业务开发打下坚实基础。

3.1 创建数据库 #

CREATE DATABASE ai_agent CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci;

3.2. .env #

.env

DATABASE_URL=mysql+pymysql://root:root@127.0.0.1:3306/ai_agent?charset=utf8mb4
CORS_ORIGINS=http://localhost:5173,http://127.0.0.1:5173

3.3. config.py #

app/config.py

# 导入Path用于路径处理(虽然本文件未直接用到)
from pathlib import Path

# 导入pydantic_settings中的BaseSettings和SettingsConfigDict用于配置管理
from pydantic_settings import BaseSettings, SettingsConfigDict

# 定义Settings类,继承自BaseSettings,便于环境变量和配置文件的读取
class Settings(BaseSettings):
    # 配置SettingsConfigDict,指定.env文件路径及编码格式,并设置额外字段忽略
    model_config = SettingsConfigDict(env_file=".env", env_file_encoding="utf-8", extra="ignore")

    # 数据库连接字符串,默认连接本地mysql数据库
    database_url: str = "mysql+pymysql://root:password@127.0.0.1:3306/agent?charset=utf8mb4"
    # 允许跨域的前端地址,多个地址以逗号分隔
    cors_origins: str = "http://localhost:5173,http://127.0.0.1:5173"

# 创建Settings实例,供项目其他部分导入使用
settings = Settings()

3.4. database.py #

app/database.py

# 导入SQLAlchemy的create_engine用于创建数据库引擎
from sqlalchemy import create_engine
# 导入sessionmaker用于会话创建,DeclarativeBase用于基类声明
from sqlalchemy.orm import sessionmaker, DeclarativeBase

# 从app.config中导入settings对象,读取配置信息
from app.config import settings

# 定义ORM模型的基类,所有模型都将继承该类
class Base(DeclarativeBase):
    pass

# 创建数据库引擎,连接方式使用settings中的database_url
# pool_pre_ping=True用于防止数据库连接断开
# pool_recycle=3600设置连接池中连接的最大存活时间为3600秒
engine = create_engine(
    settings.database_url,
    pool_pre_ping=True,
    pool_recycle=3600,
)

# 创建会话工厂,autocommit=False表示手动提交事务
# autoflush=False表示不自动刷新
# bind=engine用于绑定数据库引擎
SessionLocal = sessionmaker(autocommit=False, autoflush=False, bind=engine)

# 定义数据库会话的生成器,在依赖中使用
def get_session():
    # 创建一个数据库会话实例
    session = SessionLocal()
    try:
        # 使用yield返回会话对象
        yield session
    finally:
        # 关闭会话,释放资源
        session.close()

3.5. main.py #

main.py

# 导入uvicorn库,用于运行ASGI服务器
import uvicorn
# 从sqlalchemy导入text,用于执行原生SQL语句
+from sqlalchemy import text
# 从app.database模块导入get_session,用于获取数据库会话
+from app.database import get_session

# 尝试与数据库建立连接
+try:
    # 获取数据库会话生成器
+   db_gen = get_session()
    # 获取数据库会话实例
+   session = next(db_gen)
    # 执行一条简单的SQL语句以测试数据库连接
+   session.execute(text("SELECT 1"))
    # 打印数据库连接正常的信息
+   print("数据库连接正常")
# 如果发生异常
+except Exception as e:
    # 打印数据库连接失败的错误信息
+   print(f"数据库连接失败: {e}")
# 无论是否发生异常都会执行
+finally:
+   try:
        # 尝试关闭数据库会话生成器,释放资源
+       db_gen.close()
+   except Exception:
        # 如果关闭时发生异常则忽略
+       pass

# 判断是否为主程序运行入口
if __name__ == "__main__":
    # 启动uvicorn服务器,加载app.main模块下的app实例
    # host设置为0.0.0.0以便外部主机访问
    # port设置为8000
    # reload设置为True用于开发时自动重启
    uvicorn.run(
        "app.main:app",
        host="0.0.0.0",
        port=8000,
        reload=True,
    )

4. 数据模型 #

以下是对数据模型的详细讲解: 本项目的数据模型主要使用SQLAlchemy的声明式ORM方式定义,所有模型都继承自 app.database 中的 Base 基类。

McpService 模型

用于存储第三方MCP服务的配置信息,主要字段说明如下:

  • id:主键,自增,为每个服务分配唯一标识。
  • name:服务名称,字符串类型,设置唯一约束(unique=True)和索引(index=True),保证每个服务名称唯一并提升查询效率。
  • description:服务描述,可为 NULL,用于详细说明服务作用等扩展信息。
  • protocol:协议类型,如 HTTP, MQTT 等,不能为空。
  • config:用于保存服务的配置信息,采用 MySQL 的 JSON 类型字段,可灵活扩展不同服务的配置参数。

该表设计可拓展性强,新增服务类型或配置信息时无需改动表结构。

LlmModel 模型

用于存储大模型 API 提供方的信息和其支持的大模型列表,每个字段解释如下:

  • id:主键,自增。
  • provider_name:提供方名称,唯一且有索引,如 OpenAI、Azure。
  • provider_icon:提供方的图标地址,可以为 NULL,便于前端展示。
  • api_base_url:API 的基础请求地址,为必填项。
  • api_key:用于访问该 LLM 提供方 API 的密钥,为必填项。
  • api_key_url:密钥申请或获取地址,为可选项,便于新用户配置。
  • model_names:存储提供方所支持的具体模型列表,数据类型为 JSON,例如 ["gpt-3.5-turbo", "gpt-4"]。

这种设计便于后续动态添加更多模型或多个提供方,同时支持前端读取支持模型以动态渲染 UI。

数据类型说明

  • 采用 Mapped[...] 注解配合 mapped_column 明确字段类型,兼容 SQLAlchemy 2.0+。
  • 字符串长度(如 String(255), String(1024))根据实际业务场景预留充足长度。
  • JSON 类型便于存储动态配置信息和模型名称列表(仅在支持 JSON 的数据库后端如 MySQL 5.7+ 有效)。
  • 支持可空字段均加了 nullable=True 标记。

示例数据

  • McpService 示例:

      {
        "id": 1,
        "name": "mqtt-service",
        "description": "用于IoT设备通讯的MQTT服务",
        "protocol": "MQTT",
        "config": {
          "host": "localhost",
          "port": 1883,
          "username": "user1",
          "password": "*****"
        }
      }
  • LlmModel 示例:

      {
        "id": 1,
        "provider_name": "OpenAI",
        "provider_icon": "https://example.com/icon.png",
        "api_base_url": "https://api.openai.com/v1/",
        "api_key": "sk-****************",
        "api_key_url": "https://platform.openai.com/account/api-keys",
        "model_names": ["gpt-3.5-turbo", "gpt-4"]
      }

通过上述模型设计,可以方便扩展系统对接多种服务和大模型能力,并具备良好的灵活性与可维护性。

4.1. models.py #

app/models.py

# 导入datetime用于处理日期和时间
from datetime import datetime
# 导入SQLAlchemy中的字段类型和时间函数
from sqlalchemy import DateTime, String, Text, func
# 导入MySQL方言中的JSON类型
from sqlalchemy.dialects.mysql import JSON
# 导入ORM映射辅助工具
from sqlalchemy.orm import Mapped, mapped_column

# 导入数据库基类
from app.database import Base

# 定义McpService服务模型
class McpService(Base):
    # 设置对应的表名
    __tablename__ = "mcp_services"

    # 主键ID,自增
    id: Mapped[int] = mapped_column(primary_key=True, autoincrement=True)
    # 服务名,唯一且有索引
    name: Mapped[str] = mapped_column(String(255), unique=True, index=True)
    # 服务描述,允许为null
    description: Mapped[str | None] = mapped_column(Text, nullable=True)
    # 协议类型,必填
    protocol: Mapped[str] = mapped_column(String(32), nullable=False)
    # 配置信息,JSON格式,必填
    config: Mapped[dict] = mapped_column(JSON, nullable=False)

# 定义LlmModel大模型提供方模型
class LlmModel(Base):
    # 设置表名
    __tablename__ = "llm_models"

    # 主键ID,自增
    id: Mapped[int] = mapped_column(primary_key=True, autoincrement=True)
    # 提供方名称,唯一且有索引
    provider_name: Mapped[str] = mapped_column(String(255), unique=True, index=True)
    # 图标地址,允许为null
    provider_icon: Mapped[str | None] = mapped_column(Text, nullable=True)
    # API基础URL,必填
    api_base_url: Mapped[str] = mapped_column(String(1024), nullable=False)
    # API密钥,必填
    api_key: Mapped[str] = mapped_column(String(1024), nullable=False)
    # 密钥获取地址,可空
    api_key_url: Mapped[str | None] = mapped_column(String(1024), nullable=True)
    # 支持的模型名列表,JSON格式,必填
    model_names: Mapped[list[str]] = mapped_column(JSON, nullable=False)

# 定义Agent智能体模型
class Agent(Base):
    # 表名设置
    __tablename__ = "agents"

    # 主键、自增
    id: Mapped[int] = mapped_column(primary_key=True, autoincrement=True)
    # 头像,可空
    avatar: Mapped[str | None] = mapped_column(Text, nullable=True)
    # 名称,有索引
    name: Mapped[str] = mapped_column(String(255), index=True)
    # 描述,可空
    description: Mapped[str | None] = mapped_column(Text, nullable=True)
    # 开场消息,可空
    opening_message: Mapped[str | None] = mapped_column(Text, nullable=True)
    # 系统提示,必填
    system_prompt: Mapped[str] = mapped_column(Text, nullable=False)
    # LLM提供方名称,必填
    llm_provider_name: Mapped[str] = mapped_column(String(255), nullable=False)
    # LLM模型名称,必填
    llm_model_name: Mapped[str] = mapped_column(String(255), nullable=False)
    # MCP服务ID列表,JSON格式,必填
    mcp_service_ids: Mapped[list[int]] = mapped_column(JSON, nullable=False)
    # 询问提示词(模板),可空
    ask_prompt_template: Mapped[str | None] = mapped_column(Text, nullable=True)
    # 询问变量,默认为空列表,JSON格式,不能为空
    ask_variables: Mapped[list[dict]] = mapped_column(JSON, nullable=False, default=list)

# 定义智能体对话会话模型
class AgentChatSession(Base):
    # 表名
    __tablename__ = "agent_chat_sessions"

    # 主键,自增
    id: Mapped[int] = mapped_column(primary_key=True, autoincrement=True)
    # 智能体ID,有索引且必填
    agent_id: Mapped[int] = mapped_column(nullable=False, index=True)
    # 会话标题,必填,默认“新对话”
    title: Mapped[str] = mapped_column(String(255), nullable=False, default="新对话")
    # 创建时间,默认当前时间
    created_at: Mapped[datetime] = mapped_column(
        DateTime(timezone=False), server_default=func.now(), nullable=False
    )
    # 更新时间,默认当前时间,修改时更新
    updated_at: Mapped[datetime] = mapped_column(
        DateTime(timezone=False), server_default=func.now(), onupdate=func.now(), nullable=False
    )

# 定义智能体对话消息模型
class AgentChatMessage(Base):
    # 表名
    __tablename__ = "agent_chat_messages"

    # 主键,自增
    id: Mapped[int] = mapped_column(primary_key=True, autoincrement=True)
    # 会话ID,有索引且必填
    session_id: Mapped[int] = mapped_column(nullable=False, index=True)
    # 发送者角色(如user/agent),必填
    role: Mapped[str] = mapped_column(String(32), nullable=False)
    # 消息内容,必填
    content: Mapped[str] = mapped_column(Text, nullable=False)
    # 附加元信息,默认为空dict,JSON格式
    meta: Mapped[dict] = mapped_column(JSON, nullable=False, default=dict)
    # 消息创建时间,默认为当前时间
    created_at: Mapped[datetime] = mapped_column(
        DateTime(timezone=False), server_default=func.now(), nullable=False
    )

4.2. 表说明 #

表名 中文说明
mcp_services MCP 服务注册(名称唯一),存协议类型与连接配置 JSON
llm_models 大模型提供商配置(provider_name 唯一),含接口地址、密钥、可用模型列表
agents 智能体配置:系统提示、绑定提供商/模型名、绑定的 MCP id 列表、询问变量与模板
agent_chat_sessions 某智能体下的一次对话会话
agent_chat_messages 会话内的单条消息(角色、正文、扩展元数据)

4.2.1 mcp_services #

字段 类型 约束 说明
id int PK,自增 主键
name varchar(255) NOT NULL,唯一 服务展示/管理用名称
description text 可空 服务说明
protocol varchar(32) NOT NULL 传输协议:stdio / sse / streamable-http 等
config json NOT NULL 连接参数(命令行、URL、headers、env 等)

4.2.2 llm_models #

字段 类型 约束 说明
id int PK,自增 主键
provider_name varchar(255) NOT NULL,唯一 提供商/配置名称,与智能体里 llm_provider_name 对应
provider_icon text 可空 图标 URL 等
api_base_url varchar(1024) NOT NULL OpenAI 兼容 API 根地址
api_key varchar(1024) NOT NULL 调用密钥(敏感)
api_key_url varchar(1024) 可空 申请密钥的页面等
model_names json NOT NULL 该配置下模型 id 列表(JSON 数组)

4.2.3 agents #

字段 类型 约束 说明
id int PK,自增 主键
avatar text 可空 头像 URL 路径等
name varchar(255) NOT NULL,有索引 智能体名称
description text 可空 描述
opening_message text 可空 开场白文案
system_prompt text NOT NULL 系统提示词
llm_provider_name varchar(255) NOT NULL 对应 llm_models.provider_name(逻辑关联,非 FK)
llm_model_name varchar(255) NOT NULL 模型名,须在对应配置的 model_names 中可用
mcp_service_ids json NOT NULL 绑定的 mcp_services.id 列表(JSON 数组)
ask_prompt_template text 可空 收集完变量后拼装用户侧提示的模板(可含 {{变量}})
ask_variables json NOT NULL 变量定义列表(key、question、required 等),驱动多轮提问

4.2.4 agent_chat_sessions #

字段 类型 约束 说明
id int PK,自增 主键
agent_id int NOT NULL,有索引 所属智能体 agents.id(逻辑 FK)
title varchar(255) NOT NULL 会话标题,默认如「新对话」
created_at datetime NOT NULL,默认 now() 创建时间
updated_at datetime NOT NULL,默认 now() 更新时间(ORM 侧可能 onupdate,以库内实际为准)

4.2.5 agent_chat_messages #

字段 类型 约束 说明
id int PK,自增 主键
session_id int NOT NULL,有索引 所属会话 agent_chat_sessions.id(逻辑 FK)
role varchar(32) NOT NULL 角色:user / assistant 等
content text NOT NULL 消息正文(含模型输出、用户追问等)
meta json NOT NULL 扩展信息,如 kind:opening_message、ask_variable、ask_variable_answer 等
created_at datetime NOT NULL,默认 now() 创建时间

4.2.6 逻辑关系 #

从 到 关联方式
agent_chat_sessions.agent_id agents.id 整数引用
agent_chat_messages.session_id agent_chat_sessions.id 整数引用
agents.llm_provider_name llm_models.provider_name 字符串匹配
agents.mcp_service_ids[] mcp_services.id JSON 数组中的 id

4.3. ER 图 #

erDiagram mcp_services { int id PK varchar name UK text description varchar protocol json config } llm_models { int id PK varchar provider_name UK text provider_icon varchar api_base_url varchar api_key varchar api_key_url json model_names } agents { int id PK text avatar varchar name text description text opening_message text system_prompt varchar llm_provider_name varchar llm_model_name json mcp_service_ids text ask_prompt_template json ask_variables } agent_chat_sessions { int id PK int agent_id varchar title datetime created_at datetime updated_at } agent_chat_messages { int id PK int session_id varchar role text content json meta datetime created_at }
  • agent_chat_sessions.agent_id → agents.id
  • agent_chat_messages.session_id → agent_chat_sessions.id
  • agents.llm_provider_name ↔ llm_models.provider_name(逻辑)
  • agents.mcp_service_ids 内含 mcp_services.id(逻辑 N:M)

4.4. 高层关系 #

flowchart LR subgraph 配置层 MS[mcp_services<br/>MCP 注册] LLM[llm_models<br/>模型配置] end subgraph 智能体与对话 AG[agents<br/>智能体] SES[agent_chat_sessions<br/>会话] MSG[agent_chat_messages<br/>消息] end MS -.->|JSON id 列表| AG LLM -.->|provider_name| AG AG -->|1:N| SES SES -->|1:N| MSG

5. 建表 #

本节将详细说明如何通过 SQLAlchemy 数据模型管理上述数据表结构。

SQLAlchemy 数据模型定义

在 app/models.py 文件中,你需要为每个表定义一个对应的 SQLAlchemy ORM 类。例如:

from sqlalchemy import Column, Integer, String, Text, DateTime, JSON, ForeignKey
from sqlalchemy.orm import declarative_base, relationship
import datetime

Base = declarative_base()

class Agent(Base):
    __tablename__ = "agents"

class AgentChatSession(Base):
    __tablename__ = "agent_chat_sessions"

class AgentChatMessage(Base):
    __tablename__ = "agent_chat_messages"

自动建表

  • 自动建表:如 main.py 片段所示,Base.metadata.create_all(bind=engine) 可在应用启动时自动创建表结构。

关联关系说明

  1. agent_chat_sessions.agent_id 通过外键指向 agents.id,实现一个 Agent 关联多个 Session 的 1:N 关系。
  2. agent_chat_messages.session_id 通过外键指向 agent_chat_sessions.id,实现一个 Session 关联多条 Message 的 1:N 关系。
  3. agents.llm_provider_name 与 llm_models.provider_name 通过名称字段逻辑绑定,无物理外键。
  4. agents.mcp_service_ids 以 JSON 数组存储多个 mcp_services.id,实现灵活关联(N:M,需应用层逻辑管理)。

5.1. main.py #

app/main.py

# 导入FastAPI库
from fastapi import FastAPI
# 导入logging库以便后续日志记录
+import logging
# 导入asynccontextmanager用于异步上下文管理器
+from contextlib import asynccontextmanager
# 从app.database模块导入Base和engine,用于数据库相关操作
+from app.database import Base, engine
# 导入app.models模块下的所有内容(类、函数等)
+from app.models import * 
# 配置日志的基本设置,日志级别为INFO
+logging.basicConfig(level=logging.INFO)
# 定义一个异步上下文管理器,用于FastAPI生命周期
+@asynccontextmanager
+async def lifespan(app: FastAPI):
    # 创建所有数据库表结构(如果未存在则自动创建)
+   Base.metadata.create_all(bind=engine)
    # 通过yield挂起,等待应用关闭时进行清理
+   yield
# 创建FastAPI应用实例,设置API标题和版本号,并指定生命周期管理器
+app = FastAPI(title="智能体服务", version="0.1.0", lifespan=lifespan)
# 定义一个GET类型的/health路由用于健康检查
@app.get("/health")
def health():
    # 返回服务状态为ok的JSON响应
    return {"status": "ok"}

5.2. main.py #

main.py

# 导入uvicorn库,用于运行ASGI服务器
import uvicorn
# 判断是否为主程序运行入口
if __name__ == "__main__":
    # 启动uvicorn服务器,加载app.main模块下的app实例
    # host设置为0.0.0.0以便外部主机访问
    # port设置为8000
    # reload设置为True用于开发时自动重启
    uvicorn.run(
        "app.main:app",
        host="0.0.0.0",
        port=8000,
        reload=True,
    )

6. 跨域 #

在FastAPI中实现跨域(CORS)支持,最常用的方法是引入CORSMiddleware中间件。这样可以确保你的API能够被浏览器中的前端应用安全地访问,尤其是在本地和线上环境存在不同域名或端口时。

  • CORS(跨域资源共享):默认情况下,浏览器出于安全考虑会阻止网页访问不同源(协议、域名或端口不同)下的API。CORS是一种机制,允许服务端声明可被哪些源访问,从而实现安全的跨域请求。
  • CORSMiddleware:FastAPI中集成的中间件,配置后自动为API响应添加适当的CORS头部信息。
  1. 导入中间件和配置
    • from fastapi.middleware.cors import CORSMiddleware
    • from app.config import settings
  2. 解析 CORS 允许的来源
    • 通常会将允许的域名列表写在环境变量(示例:settings.cors_origins),用英文逗号分隔。
    • 通过列表推导式进行分割和清理空格、去除空字符串,得到最终的origins列表。
  3. 注册中间件
    • 使用app.add_middleware(...)方法,把CORSMiddleware加到FastAPI应用上。
    • 通常建议设置:
      • allow_origins: 可访问的源组成的列表(如开发阶段通常允许所有源,生产环境请精确配置)。
      • allow_credentials: 是否允许cookie、认证等凭证。
      • allow_methods与allow_headers均设为["*"],表示不限制方法和头部字段。

示例场景

  • 前端(如本地 http://localhost:3000)开发时访问本后端API
  • 生产环境下只允许公司域名访问API

此配置提升了服务的灵活性和安全性。

常见问题

  • 配置了 CORS 但仍报跨域错误?请检查:
    • 前端请求地址(端口、协议等是否与允许列表对应)
    • allow_origins 是否包含了请求源
    • nginx、网关等外层代理是否覆盖或删改了CORS相关头信息

推荐做法

  • 开发环境:cors_origins可设为*或http://localhost:3000等前端地址。
  • 生产环境:cors_origins应精确枚举允许的正式域名,防止被恶意第三方利用。

6.1. main.py #

app/main.py

# 导入FastAPI库
from fastapi import FastAPI
# 导入logging库以便后续日志记录
import logging
# 导入asynccontextmanager用于异步上下文管理器
from contextlib import asynccontextmanager
# 导入FastAPI的CORS中间件,用于跨域资源共享
+from fastapi.middleware.cors import CORSMiddleware
# 导入项目配置settings对象
+from app.config import settings
# 从app.database模块导入Base和engine,用于数据库相关操作
from app.database import Base, engine
# 导入app.models模块下的所有内容(类、函数等)
from app.models import * 
# 配置日志的基本设置,日志级别为INFO
logging.basicConfig(level=logging.INFO)
# 定义一个异步上下文管理器,用于FastAPI生命周期
@asynccontextmanager
async def lifespan(app: FastAPI):
    # 创建所有数据库表结构(如果未存在则自动创建)
    Base.metadata.create_all(bind=engine)
    # 通过yield挂起,等待应用关闭时进行清理
    yield
# 创建FastAPI应用实例,设置API标题和版本号,并指定生命周期管理器
app = FastAPI(title="智能体服务", version="0.1.0", lifespan=lifespan)
# 解析配置中的CORS来源列表,去除空白项和空字符串
+origins = [o.strip() for o in settings.cors_origins.split(",") if o.strip()]
# 向FastAPI应用添加CORS中间件
+app.add_middleware(
+   CORSMiddleware,
    # 允许访问的来源列表,如果为空则允许所有来源("*")
+   allow_origins=origins or ["*"],
    # 允许携带cookie等凭证
+   allow_credentials=True,
    # 允许所有HTTP方法
+   allow_methods=["*"],
    # 允许所有HTTP头
+   allow_headers=["*"],
+)
# 定义一个GET类型的/health路由用于健康检查
@app.get("/health")
def health():
    # 返回服务状态为ok的JSON响应
    return {"status": "ok"}

7. 添加MCP服务 #

本节我们将为项目添加 MCP 服务 (即多通道处理服务,Multi-Channel Processing Service)。这一部分内容主要包括:MCP 服务的数据模型定义、接口(API)定义、数据库操作方法(Repository)实现、以及路由(Router)注册流程。

主要内容如下:

  1. 数据模型(Model)和序列化(Schema)

    • 在 app/models.py 中增加 McpService 模型,描述 MCP 服务的数据结构及其字段(如名称、描述、协议类型、配置信息等)。
    • 在 app/schemas.py 中定义与模型对应的输入和输出序列化类,以用于数据校验和文档自动生成。
  2. 数据库操作(Repository)

    • 在 app/repositories/mcp_repository.py 中实现 MCP 服务的增删查改方法。比如 create_mcp_service 函数,用于插入新的 MCP 服务记录,该函数会接收数据库会话对象和待插入的数据对象,处理后将数据持久化到数据库。
  3. 接口路由(Router)

    • 路由文件(比如 app/routers/mcp.py)定义 MCP 服务的相关 API 接口,如创建服务、查询服务列表、获取详情、删除服务等。这些接口会调用 repository 层实现实际的数据操作。
    • 路由注册通常会在 FastAPI 主实例中(如 app/main.py)统一挂载。
  4. 依赖注入与请求/响应模型

    • 结合 FastAPI 的依赖注入特性,通过参数注入 Session 数据库会话、以及参数校验自动对接相关 pydantic 数据模型。
  5. 具体开发流程

    • 先设计并创建数据表和模型;
    • 再实现操作数据库的 repository 层方法;
    • 编写与之匹配的 schema;
    • 最后写 API 路由和接口逻辑,并将路由注册到应用主程序。

本节内容有助于你了解如何使用 FastAPI 构建结构清晰、解耦良好的 RESTful 服务,便于后续的功能扩展和维护。你可以根据需求灵活调整服务字段及接口设计。

7.1. init.py #

app/repositories/init.py

from . import mcp_repository

__all__ = ["mcp_repository"]

7.2. mcp_repository.py #

app/repositories/mcp_repository.py

# 导入SQLAlchemy的select
from sqlalchemy import select
# 导入SQLAlchemy的Session对象
from sqlalchemy.orm import Session

# 导入项目中的models模块
from app import models
# 导入项目中的schemas模块
from app import schemas

# 定义创建McpService的函数,接收数据库会话db和待创建数据data,返回新建的McpService对象
def create_mcp_service(session: Session, data: schemas.McpServiceCreate) -> models.McpService:
    # 构造McpService模型对象,strip去除名字首尾空白
    row = models.McpService(
        name=data.name.strip(),#去除名字首尾空白
        description=data.description,#服务描述
        protocol=data.protocol.value,#协议类型
        config=data.config,#配置信息
    )
    # 添加新对象到会话
    session.add(row)
    # 提交事务,将更改保存到数据库
    session.commit()
    # 刷新实例,确保row包含数据库自动生成的字段值
    session.refresh(row)
    # 返回新建的McpService对象
    return row

7.3. init.py #

app/routers/init.py

# routers

7.4. mcp_services.py #

app/routers/mcp_services.py

# 引入日志模块
import logging

# 从FastAPI导入路由器、依赖项和HTTP异常类
from fastapi import APIRouter, Depends, HTTPException
# 从SQLAlchemy导入唯一性错误异常
from sqlalchemy.exc import IntegrityError
# 从SQLAlchemy导入ORM会话对象
from sqlalchemy.orm import Session

# 导入应用程序的数据模型
from app import schemas
# 导入mcp_repository模块,包含数据库操作方法
from app.repositories import mcp_repository
# 导入用于获取数据库会话的依赖函数
from app.database import get_session

# 创建API路由器,设置前缀和标签
router = APIRouter(prefix="/api/mcp-services", tags=["mcp-services"])
# 获取当前模块的日志记录器
logger = logging.getLogger(__name__)

# 定义创建服务的POST接口,响应模型为McpServiceOut
@router.post("", response_model=schemas.McpServiceOut)
def create_service(payload: schemas.McpServiceCreate, session: Session = Depends(get_session)):
    # 使用try-except来捕捉数据库插入冲突
    try:
        # 调用仓库方法创建服务
        return mcp_repository.create_mcp_service(session, payload)
    # 捕获唯一性约束异常(如名称重复)
    except IntegrityError:
        # 回滚数据库会话,撤消操作
        session.rollback()
        # 抛出409冲突异常,返回错误信息
        raise HTTPException(status_code=409, detail="名称已存在")

7.5. schemas.py #

app/schemas.py


# 导入枚举类型
from enum import Enum
# 导入Any类型用于类型注解
from typing import Any

# 从pydantic导入BaseModel、Field和field_validator用于数据验证
from pydantic import BaseModel, Field, field_validator

# 定义MCP协议类型的枚举类
class McpProtocol(str, Enum):
    # MCP 协议类型注释
    """MCP 协议类型"""
    # stdio协议
    stdio = "stdio"
    # streamable-http协议
    streamable_http = "streamable-http"
    # sse协议
    sse = "sse"

# 定义MCP服务基础模型
class McpServiceBase(BaseModel):
    # MCP 服务基础模型注释
    """MCP 服务基础模型"""
    # 服务名称,字符串类型,长度1-255
    name: str = Field(..., min_length=1, max_length=255)
    # 服务描述,可选字段,字符串或None
    description: str | None = None
    # 协议类型,使用McpProtocol枚举
    protocol: McpProtocol
    # 配置信息,要求为字典类型
    config: dict[str, Any]

    # 对config字段添加验证器,确保其为字典类型
    @field_validator("config", mode="before")
    @classmethod
    def config_not_empty(cls, v: Any) -> Any:
        # 验证 config 是否为 JSON 对象,如果不是字典则抛出异常
        """验证 config 是否为 JSON 对象"""
        if not isinstance(v, dict):
            raise ValueError("config 必须为 JSON 对象")
        return v

# 定义MCP服务创建模型,继承自McpServiceBase
class McpServiceCreate(McpServiceBase):
    # 不增加额外内容,直接继承
    pass

# 定义MCP服务输出模型
class McpServiceOut(BaseModel):
    # ID字段,整型
    id: int
    # 服务名称
    name: str
    # 服务描述,可选字段
    description: str | None
    # 协议类型,字符串
    protocol: str
    # 配置信息,字典类型
    config: dict[str, Any]

    # 设置模型配置,允许从ORM对象属性读取数据
    model_config = {"from_attributes": True}

7.6. main.py #

app/main.py

# 导入FastAPI库
from fastapi import FastAPI
# 导入logging库以便后续日志记录
import logging
# 导入asynccontextmanager用于异步上下文管理器
from contextlib import asynccontextmanager
# 导入FastAPI的CORS中间件,用于跨域资源共享
from fastapi.middleware.cors import CORSMiddleware
# 导入项目配置settings对象
from app.config import settings
# 从app.database模块导入Base和engine,用于数据库相关操作
from app.database import Base, engine
# 导入app.models模块下的所有内容(类、函数等)
from app.models import * 
# 导入MCP服务路由
+from app.routers import mcp_services
# 配置日志的基本设置,日志级别为INFO
logging.basicConfig(level=logging.INFO)
# 定义一个异步上下文管理器,用于FastAPI生命周期
@asynccontextmanager
async def lifespan(app: FastAPI):
    # 创建所有数据库表结构(如果未存在则自动创建)
    Base.metadata.create_all(bind=engine)
    # 通过yield挂起,等待应用关闭时进行清理
    yield
# 创建FastAPI应用实例,设置API标题和版本号,并指定生命周期管理器
app = FastAPI(title="智能体服务", version="0.1.0", lifespan=lifespan)
# 解析配置中的CORS来源列表,去除空白项和空字符串
origins = [o.strip() for o in settings.cors_origins.split(",") if o.strip()]
# 向FastAPI应用添加CORS中间件
app.add_middleware(
    CORSMiddleware,
    # 允许访问的来源列表,如果为空则允许所有来源("*")
    allow_origins=origins or ["*"],
    # 允许携带cookie等凭证
    allow_credentials=True,
    # 允许所有HTTP方法
    allow_methods=["*"],
    # 允许所有HTTP头
    allow_headers=["*"],
)
# 包含MCP服务路由
+app.include_router(mcp_services.router)
# 定义一个GET类型的/health路由用于健康检查
@app.get("/health")
def health():
    # 返回服务状态为ok的JSON响应
    return {"status": "ok"}

7.7 测试 #

curl --location --request POST "http://127.0.0.1:8000/api/mcp-services" --header "Content-Type: application/json" --data-raw "{\"name\": \"name\",\"description\": \"description\",\"protocol\": \"streamable-http\",\"config\": {\"url\": \"http://127.0.0.1:8002/mcp\",\"headers\": {\"BAIDU_MAP_AK\": \"xxx\",\"DEEPSEEK_API_KEY\": \"yyy\"}}}"

curl --location --request POST "http://127.0.0.1:8000/api/mcp-services" --header "Content-Type: application/json" --data-binary "@payload.json"

payload.json

{
  "name": "路线规划服务",
  "description": "路线规划服务",
  "protocol": "streamable-http",
  "config": {
    "url": "http://127.0.0.1:8002/mcp",
    "headers": {
      "BAIDU_MAP_AK": "51wJobm0zfEyyZv1FGSAGmvrBAXGrqU9",
      "DEEPSEEK_API_KEY": "sk-24088156e9ab48f3adddaf5a9c0c4ede"
    }
  }
}

curl

  • 命令行工具,用于发送 HTTP/HTTPS 等协议的请求。

--location

  • 如果服务器返回 3xx 重定向响应,curl 会自动跟随重定向,向新的地址再次发起请求。
  • 这里可能用不到,但加上可确保在服务端有重定向时请求仍能成功。

--request POST

  • 显式指定 HTTP 方法为 POST。
  • 如果省略,curl 会根据数据自动判断(例如有 --data 时会默认为 POST),但显式声明更清晰。

"http://127.0.0.1:8000/api/mcp-services"

  • 请求的目标 URL。
  • 127.0.0.1 是本机地址,端口 8000,路径 /api/mcp-services。
  • 通常这是一个 RESTful 接口,用于创建新的 MCP 服务。

`--header "Content-Type: application/json"

  • 添加 HTTP 请求头,告知服务器请求体是 JSON 格式。
  • 服务器一般会根据这个头来解析请求体内容。

`--data-raw "..."

  • 指定请求体(body)的原始内容。
  • 与 --data 的区别:
    • --data 会将 @ 开头的字符串解释为文件名,读取文件内容;
    • --data-raw 对 @ 不作特殊处理,原样发送。
  • 这里使用 --data-raw 可以避免 JSON 中的 @ 被误解析(虽然本例 JSON 中没有 @)。
  • 注意整个 JSON 被双引号包裹,内部的引号需要用反斜杠转义。

请求体(JSON 结构)

{
  "name": "name",
  "description": "description",
  "protocol": "streamable-http",
  "config": {
    "url": "http://127.0.0.1:8002/mcp",
    "headers": {
      "BAIDU_MAP_AK": "xxx",
      "DEEPSEEK_API_KEY": "yyy"
    }
  }
}
  • name 和 description:服务的名称和描述。
  • protocol:服务使用的协议,这里是 streamable-http(一种可能用于流式传输的 HTTP 扩展)。
  • config:服务的具体配置。
    • url:服务实际监听的地址。
    • headers:调用该服务时需要附加的 HTTP 头,包含百度地图 API Key 和 DeepSeek API Key(示例中占位为 xxx 和 yyy)。

总结:该命令通过 POST 方式向本机 8000 端口的 MCP 服务管理接口发送一个 JSON 格式的服务定义,用于注册一个新的路线规划服务。

8. 查看MCP服务列表 #

在本节将讲解如何通过 API 查看已注册的所有 MCP 服务。

  • 典型用法:获取所有已注册的路线规划服务列表。

示例 curl 命令

curl -X GET "http://127.0.0.1:8000/api/mcp-services"

请求说明

  • -X GET:指定 HTTP 方法为 GET,获取资源。
  • URL:指向用于获取 MCP 服务列表的 API 路径。

响应格式

接口会返回 MCP 服务的 JSON 数组。每个元素包含服务的详细信息。例如:

[
  {
    "id": 1,
    "name": "name",
    "description": "description",
    "protocol": "streamable-http",
    "config": {
      "url": "http://127.0.0.1:8002/mcp",
      "headers": {
        "BAIDU_MAP_AK": "xxx",
        "DEEPSEEK_API_KEY": "yyy"
      }
    },
    "created_at": "2024-05-01T12:00:00"
  }
]

字段说明

  • id:服务唯一标识。
  • name:服务名称。
  • description:服务描述。
  • protocol:协议类型(如 streamable-http)。
  • config:配置信息(如服务调用地址和所需 header)。
  • created_at:服务注册时间。

实现原理

  • 后端路由 /api/mcp-services 配置了 GET 方法的处理函数。
  • 查询数据库,按 id 倒序取出所有 MCP 服务。
  • 使用 Pydantic Schema 自动转换数据库对象为 JSON 格式返回前端。

总结:通过该接口可以随时查看注册在 MCP 管理平台下的所有服务及其详情,便于统一运维和后续调用。

8.1. mcp_repository.py #

app/repositories/mcp_repository.py

# 导入SQLAlchemy的select
from sqlalchemy import select
# 导入SQLAlchemy的Session对象
from sqlalchemy.orm import Session

# 导入项目中的models模块
from app import models
# 导入项目中的schemas模块
from app import schemas

# 定义创建McpService的函数,接收数据库会话db和待创建数据data,返回新建的McpService对象
def create_mcp_service(session: Session, data: schemas.McpServiceCreate) -> models.McpService:
    # 构造McpService模型对象,strip去除名字首尾空白
    row = models.McpService(
        name=data.name.strip(),#去除名字首尾空白
        description=data.description,#服务描述
        protocol=data.protocol.value,#协议类型
        config=data.config,#配置信息
    )
    # 添加新对象到会话
    session.add(row)
    # 提交事务,将更改保存到数据库
    session.commit()
    # 刷新实例,确保row包含数据库自动生成的字段值
    session.refresh(row)
    # 返回新建的McpService对象
    return row

# 定义获取所有McpService对象的函数,参数为数据库会话db,返回McpService对象列表
+def list_mcp_services(session: Session) -> list[models.McpService]:
    # 构造按id倒序排序的查询,获取所有McpService记录
+   return list(session.scalars(select(models.McpService).order_by(models.McpService.id.desc())).all())

8.2. mcp_services.py #

app/routers/mcp_services.py

# 引入日志模块
import logging

# 从FastAPI导入路由器、依赖项和HTTP异常类
from fastapi import APIRouter, Depends, HTTPException
# 从SQLAlchemy导入唯一性错误异常
from sqlalchemy.exc import IntegrityError
# 从SQLAlchemy导入ORM会话对象
from sqlalchemy.orm import Session

# 导入应用程序的数据模型
from app import schemas
# 导入mcp_repository模块,包含数据库操作方法
from app.repositories import mcp_repository
# 导入用于获取数据库会话的依赖函数
from app.database import get_session

# 创建API路由器,设置前缀和标签
router = APIRouter(prefix="/api/mcp-services", tags=["mcp-services"])
# 获取当前模块的日志记录器
logger = logging.getLogger(__name__)


# 定义GET方法,用于列出所有已注册的MCP服务,返回值为McpServiceOut的列表
+@router.get("", response_model=list[schemas.McpServiceOut])
+def list_services(session: Session = Depends(get_session)):
    # 调用mcp_repository中的list_mcp_services方法,查询数据库中的所有服务
+   return mcp_repository.list_mcp_services(session)

# 定义POST方法,用于创建一个新的MCP服务,接收McpServiceCreate模式对象作为请求体
@router.post("", response_model=schemas.McpServiceOut)
def create_service(payload: schemas.McpServiceCreate, session: Session = Depends(get_session)):
    try:
        # 调用mcp_repository中的create_mcp_service方法,将新服务信息写入数据库并返回
        return mcp_repository.create_mcp_service(session, payload)
    except IntegrityError:
        # 捕获唯一性约束异常,如服务名称已存在,进行回滚操作
        session.rollback()
        # 抛出HTTP异常,状态码409,表示名称冲突
+       raise HTTPException(status_code=409, detail="名称已存在")

8.3. main.py #

main.py

# 导入uvicorn库,用于运行ASGI服务器
import uvicorn
# 判断是否为主程序运行入口
if __name__ == "__main__":
    # 启动uvicorn服务器,加载app.main模块下的app实例
    # host设置为0.0.0.0以便外部主机访问
    # port设置为8000
    # reload设置为True用于开发时自动重启
    uvicorn.run(
        "app.main:app",
        host="0.0.0.0",
        port=8000,
+       reload=False,
    )

9. 查看MCP服务 #

实际开发中,通常我们需要按唯一主键(如ID)来获取某个具体的服务(即查询详情页)。相比“全部列表”,详情接口返回单条数据,通常会在前端页面的“详情弹窗”或“编辑表单”场景里用到。

在本项目中,你需要实现如下步骤:

  1. Repository 层实现
    在 app/repositories/mcp_repository.py 中,新增了 get_mcp_service 函数。它接收数据库会话 session 和服务主键 mcp_id:

    • 核心语句 session.get(models.McpService, mcp_id),利用 SQLAlchemy 的 get 方法直接按主键查询数据,高效且简洁。
    • 如果数据库存在该 ID 的记录,则返回模型实例;否则返回 None。
  2. Router 层接口实现
    在 app/routers/mcp_services.py 中,新增了获取单个服务的 GET 路由:

    • 路径为 /api/mcp-services/{mcp_id},参数取自 URL 路径。
    • 首先利用 repository 层的 get_mcp_service 查询。
    • 若结果不存在,使用 FastAPI 的 HTTPException 抛出 404 状态码提示“记录不存在”;否则将记录返回,自动转换为输出 Schema。
  3. 接口使用示例
    使用 curl 或 Postman 请求:

    GET /api/mcp-services/1

    返回值为指定 ID 的 MCP 服务详细信息,若找不到则返回 404 错误提示。

这种方式能更好地支持前端“点击查看详情”或“编辑实体”这种典型场景,编程实践中十分常见。

小提示: repository 层一般只负责数据库操作,不直接处理异常和 HTTP 相关逻辑,路由层负责捕获异常并给出联动响应。

9.1. mcp_repository.py #

app/repositories/mcp_repository.py

# 导入SQLAlchemy的select
from sqlalchemy import select
# 导入SQLAlchemy的Session对象
from sqlalchemy.orm import Session

# 导入项目中的models模块
from app import models
# 导入项目中的schemas模块
from app import schemas

# 定义创建McpService的函数,接收数据库会话db和待创建数据data,返回新建的McpService对象
def create_mcp_service(session: Session, data: schemas.McpServiceCreate) -> models.McpService:
    # 构造McpService模型对象,strip去除名字首尾空白
    row = models.McpService(
        name=data.name.strip(),#去除名字首尾空白
        description=data.description,#服务描述
        protocol=data.protocol.value,#协议类型
        config=data.config,#配置信息
    )
    # 添加新对象到会话
    session.add(row)
    # 提交事务,将更改保存到数据库
    session.commit()
    # 刷新实例,确保row包含数据库自动生成的字段值
    session.refresh(row)
    # 返回新建的McpService对象
    return row

# 定义获取所有McpService对象的函数,参数为数据库会话db,返回McpService对象列表
def list_mcp_services(session: Session) -> list[models.McpService]:
    # 构造按id倒序排序的查询,获取所有McpService记录
    return list(session.scalars(select(models.McpService).order_by(models.McpService.id.desc())).all())


# 定义一个函数,根据传入的mcp_id从数据库中获取对应的McpService对象
# 参数db为数据库会话对象,mcp_id为服务的主键ID
# 如果找到则返回对应的McpService对象,否则返回None
+def get_mcp_service(session: Session, mcp_id: int) -> models.McpService | None:
    # 调用SQLAlchemy的get方法根据主键查询McpService
+   return session.get(models.McpService, mcp_id)

9.2. mcp_services.py #

app/routers/mcp_services.py

# 引入日志模块
import logging

# 从FastAPI导入路由器、依赖项和HTTP异常类
from fastapi import APIRouter, Depends, HTTPException
# 从SQLAlchemy导入唯一性错误异常
from sqlalchemy.exc import IntegrityError
# 从SQLAlchemy导入ORM会话对象
from sqlalchemy.orm import Session

# 导入应用程序的数据模型
from app import schemas
# 导入mcp_repository模块,包含数据库操作方法
from app.repositories import mcp_repository
# 导入用于获取数据库会话的依赖函数
from app.database import get_session

# 创建API路由器,设置前缀和标签
router = APIRouter(prefix="/api/mcp-services", tags=["mcp-services"])
# 获取当前模块的日志记录器
logger = logging.getLogger(__name__)


# 定义GET方法,用于列出所有已注册的MCP服务,返回值为McpServiceOut的列表
@router.get("", response_model=list[schemas.McpServiceOut])
def list_services(session: Session = Depends(get_session)):
    # 调用mcp_repository中的list_mcp_services方法,查询数据库中的所有服务
    return mcp_repository.list_mcp_services(session)

# 定义POST方法,用于创建一个新的MCP服务,接收McpServiceCreate模式对象作为请求体
@router.post("", response_model=schemas.McpServiceOut)
def create_service(payload: schemas.McpServiceCreate, session: Session = Depends(get_session)):
    try:
        # 调用mcp_repository中的create_mcp_service方法,将新服务信息写入数据库并返回
        return mcp_repository.create_mcp_service(session, payload)
    except IntegrityError:
        # 捕获唯一性约束异常,如服务名称已存在,进行回滚操作
        session.rollback()
        # 抛出HTTP异常,状态码409,表示名称冲突
        raise HTTPException(status_code=409, detail="名称已存在")


# 定义一个GET类型的路由,用于根据mcp_id获取单个MCP服务,返回McpServiceOut模型
+@router.get("/{mcp_id}", response_model=schemas.McpServiceOut)
# 处理函数:根据传入的mcp_id和数据库会话读取指定的服务
+def get_service(mcp_id: int, session: Session = Depends(get_session)):
    # 调用仓库方法获取对应id的MCP服务记录
+   row = mcp_repository.get_mcp_service(session, mcp_id)
    # 如果记录不存在,则抛出404异常
+   if not row:
+       raise HTTPException(status_code=404, detail="记录不存在")
    # 返回查找到的服务记录
+   return row        
curl --location --request GET "http://127.0.0.1:8000/api/mcp-services?mcp_id=1"

10. 更新MCP服务 #

本节将介绍如何通过API接口实现对已有MCP服务信息的更新操作。通常我们使用HTTP的PUT或PATCH方法,通过指定服务的mcp_id并传递需要更新的数据,对已有的服务进行修改。此操作适用于修改服务的描述、配置、协议类型等字段。

接口说明

  • 接口路径:/api/mcp-services/{mcp_id}
  • 请求方法:PUT(全部字段更新)或PATCH(部分字段更新,推荐)
  • 请求参数:
    • 路径参数:mcp_id (int) — MCP服务的唯一标识符
    • 请求体:JSON格式,内容需符合McpServiceUpdate或McpServicePatch模型
  • 返回数据:更新后的MCP服务对象

典型请求示例

curl --location --request PATCH "http://127.0.0.1:8000/api/mcp-services/1" \
  --header "Content-Type: application/json" \
  --data-raw '{
    "description": "新的服务描述",
    "protocol": "http",
    "config": {
      "url": "https://example.com/updated"
    }
  }'

典型响应

{
  "id": 1,
  "name": "MyMcp",
  "description": "新的服务描述",
  "protocol": "http",
  "config": {
    "url": "https://example.com/updated"
  }
}

注意事项

  • 更新操作会根据传入的内容,按需更新MCP服务表中的相关字段。未提供的字段保持不变。
  • 若更新的服务名称与数据库中已存在的其他服务名称冲突,会返回409冲突错误。
  • 修改不存在的mcp_id将返回404错误。

10.1. mcp_repository.py #

app/repositories/mcp_repository.py

# 导入SQLAlchemy的select
from sqlalchemy import select
# 导入SQLAlchemy的Session对象
from sqlalchemy.orm import Session

# 导入项目中的models模块
from app import models
# 导入项目中的schemas模块
from app import schemas

# 定义创建McpService的函数,接收数据库会话db和待创建数据data,返回新建的McpService对象
def create_mcp_service(session: Session, data: schemas.McpServiceCreate) -> models.McpService:
    # 构造McpService模型对象,strip去除名字首尾空白
    row = models.McpService(
        name=data.name.strip(),#去除名字首尾空白
        description=data.description,#服务描述
        protocol=data.protocol.value,#协议类型
        config=data.config,#配置信息
    )
    # 添加新对象到会话
    session.add(row)
    # 提交事务,将更改保存到数据库
    session.commit()
    # 刷新实例,确保row包含数据库自动生成的字段值
    session.refresh(row)
    # 返回新建的McpService对象
    return row

# 定义获取所有McpService对象的函数,参数为数据库会话db,返回McpService对象列表
def list_mcp_services(session: Session) -> list[models.McpService]:
    # 构造按id倒序排序的查询,获取所有McpService记录
    return list(session.scalars(select(models.McpService).order_by(models.McpService.id.desc())).all())


# 定义一个函数,根据传入的mcp_id从数据库中获取对应的McpService对象
# 参数db为数据库会话对象,mcp_id为服务的主键ID
# 如果找到则返回对应的McpService对象,否则返回None
def get_mcp_service(session: Session, mcp_id: int) -> models.McpService | None:
    # 调用SQLAlchemy的get方法根据主键查询McpService
    return session.get(models.McpService, mcp_id)

# 定义一个函数,通过服务名称从数据库中获取对应的McpService对象
# 参数db为数据库会话对象,name为服务名称
# 如果找到则返回对应的McpService对象,否则返回None
+def get_by_name(session: Session, name: str) -> models.McpService | None:
    # 构造查询,根据名称筛选McpService记录,并返回第一条结果
+   return session.scalar(select(models.McpService).where(models.McpService.name == name))

# 定义一个函数,更新指定的McpService对象内容
# 参数db为数据库会话对象,row为待更新的McpService对象,data为更新数据
# 返回更新后的McpService对象
+def update_mcp_service(session: Session, row: models.McpService, data: schemas.McpServiceUpdate) -> models.McpService:
    # 如果更新数据中name字段不为空,则去除首尾空白后赋值
+   if data.name is not None:
+       row.name = data.name.strip()
    # 如果更新数据中description字段不为空,则赋值
+   if data.description is not None:
+       row.description = data.description
    # 如果更新数据中protocol字段不为空,则将其值转为字符串后赋值
+   if data.protocol is not None:
+       row.protocol = data.protocol.value
    # 如果更新数据中config字段不为空,则赋值
+   if data.config is not None:
+       row.config = data.config
    # 提交事务,将更改保存到数据库
+   session.commit()
    # 刷新实例,确保row包含最新的字段值
+   session.refresh(row)
    # 返回更新后的McpService对象
+   return row

10.2. mcp_services.py #

app/routers/mcp_services.py

# 引入日志模块
import logging

# 从FastAPI导入路由器、依赖项和HTTP异常类
from fastapi import APIRouter, Depends, HTTPException
# 从SQLAlchemy导入唯一性错误异常
from sqlalchemy.exc import IntegrityError
# 从SQLAlchemy导入ORM会话对象
from sqlalchemy.orm import Session

# 导入应用程序的数据模型
from app import schemas
# 导入mcp_repository模块,包含数据库操作方法
from app.repositories import mcp_repository
# 导入用于获取数据库会话的依赖函数
from app.database import get_session

# 创建API路由器,设置前缀和标签
router = APIRouter(prefix="/api/mcp-services", tags=["mcp-services"])
# 获取当前模块的日志记录器
logger = logging.getLogger(__name__)


# 定义GET方法,用于列出所有已注册的MCP服务,返回值为McpServiceOut的列表
@router.get("", response_model=list[schemas.McpServiceOut])
def list_services(session: Session = Depends(get_session)):
    # 调用mcp_repository中的list_mcp_services方法,查询数据库中的所有服务
    return mcp_repository.list_mcp_services(session)

# 定义POST方法,用于创建一个新的MCP服务,接收McpServiceCreate模式对象作为请求体
@router.post("", response_model=schemas.McpServiceOut)
def create_service(payload: schemas.McpServiceCreate, session: Session = Depends(get_session)):
    try:
        # 调用mcp_repository中的create_mcp_service方法,将新服务信息写入数据库并返回
        return mcp_repository.create_mcp_service(session, payload)
    except IntegrityError:
        # 捕获唯一性约束异常,如服务名称已存在,进行回滚操作
        session.rollback()
        # 抛出HTTP异常,状态码409,表示名称冲突
        raise HTTPException(status_code=409, detail="名称已存在")


# 定义一个GET类型的路由,用于根据mcp_id获取单个MCP服务,返回McpServiceOut模型
@router.get("/{mcp_id}", response_model=schemas.McpServiceOut)
# 处理函数:根据传入的mcp_id和数据库会话读取指定的服务
def get_service(mcp_id: int, session: Session = Depends(get_session)):
    # 调用仓库方法获取对应id的MCP服务记录
    row = mcp_repository.get_mcp_service(session, mcp_id)
    # 如果记录不存在,则抛出404异常
    if not row:
        raise HTTPException(status_code=404, detail="记录不存在")
    # 返回查找到的服务记录
    return row        

# 定义PUT方法用于更新指定ID的MCP服务,返回更新后的服务对象
+@router.put("/{mcp_id}", response_model=schemas.McpServiceOut)
# 处理函数,参数包括服务ID、更新请求体、数据库会话
+def update_service(mcp_id: int, payload: schemas.McpServiceUpdate, session: Session = Depends(get_session)):
    # 首先根据mcp_id从数据库查找对应的服务记录
+   row = mcp_repository.get_mcp_service(session, mcp_id)
    # 如果未找到记录,则抛出404异常提示记录不存在
+   if not row:
+       raise HTTPException(status_code=404, detail="记录不存在")
    # 如果更新请求中包含新的名称
+   if payload.name is not None:
        # 通过名称查找是否存在其他服务
+       other = mcp_repository.get_by_name(session, payload.name.strip())
        # 如果找到的其他服务ID与当前更新目标不同,说明名称已被占用
+       if other and other.id != mcp_id:
+           raise HTTPException(status_code=409, detail="名称已被其他记录使用")
+   try:
        # 调用repository方法执行数据库更新并返回结果
+       return mcp_repository.update_mcp_service(session, row, payload)
+   except IntegrityError:
        # 捕获唯一约束冲突,回滚事务
+       session.rollback()
        # 抛出409异常提示名称冲突
+       raise HTTPException(status_code=409, detail="名称冲突")    

10.3. schemas.py #

app/schemas.py


# 导入枚举类型
from enum import Enum
# 导入Any类型用于类型注解
from typing import Any

# 从pydantic导入BaseModel、Field和field_validator用于数据验证
from pydantic import BaseModel, Field, field_validator

# 定义MCP协议类型的枚举类
class McpProtocol(str, Enum):
    # MCP 协议类型注释
    """MCP 协议类型"""
    # stdio协议
    stdio = "stdio"
    # streamable-http协议
    streamable_http = "streamable-http"
    # sse协议
    sse = "sse"

# 定义MCP服务基础模型
class McpServiceBase(BaseModel):
    # MCP 服务基础模型注释
    """MCP 服务基础模型"""
    # 服务名称,字符串类型,长度1-255
    name: str = Field(..., min_length=1, max_length=255)
    # 服务描述,可选字段,字符串或None
    description: str | None = None
    # 协议类型,使用McpProtocol枚举
    protocol: McpProtocol
    # 配置信息,要求为字典类型
    config: dict[str, Any]

    # 对config字段添加验证器,确保其为字典类型
    @field_validator("config", mode="before")
    @classmethod
    def config_not_empty(cls, v: Any) -> Any:
        # 验证 config 是否为 JSON 对象,如果不是字典则抛出异常
        """验证 config 是否为 JSON 对象"""
        if not isinstance(v, dict):
            raise ValueError("config 必须为 JSON 对象")
        return v

# 定义MCP服务创建模型,继承自McpServiceBase
class McpServiceCreate(McpServiceBase):
    # 不增加额外内容,直接继承
    pass

# 定义MCP服务输出模型
class McpServiceOut(BaseModel):
    # ID字段,整型
    id: int
    # 服务名称
    name: str
    # 服务描述,可选字段
    description: str | None
    # 协议类型,字符串
    protocol: str
    # 配置信息,字典类型
    config: dict[str, Any]

    # 设置模型配置,允许从ORM对象属性读取数据
    model_config = {"from_attributes": True}

# 定义McpServiceUpdate模型,用于更新MCP服务,支持部分字段可选更新
+class McpServiceUpdate(BaseModel):
    # 服务名称,允许为None,最小长度1,最大长度255
+   name: str | None = Field(None, min_length=1, max_length=255)
    # 服务描述字段,允许为None
+   description: str | None = None
    # 协议类型,使用McpProtocol枚举,允许为None
+   protocol: McpProtocol | None = None
    # 配置信息,允许为None,类型为字典
+   config: dict[str, Any] | None = None    

10.4 测试 #

curl --location --request PUT "http://127.0.0.1:8000/api/mcp-services/1" ^
--header "Content-Type: application/json" ^
--data-raw "{  \"name\": \"路线规划服务\",  \"description\": \"路线规划服务\",  \"protocol\": \"streamable-http\",  \"config\": {    \"url\": \"http://127.0.0.1:8002/mcp\",    \"headers\": {      \"BAIDU_MAP_AK\": \"51wJobm0zfEyyZv1FGSAGmvrBAXGrqU9\",      \"DEEPSEEK_API_KEY\": \"sk-24088156e9ab48f3adddaf5a9c0c4ede\"    }  }}"

11. 删除MCP服务 #

本节将介绍如何通过API接口删除(移除)已有的MCP服务。删除操作会物理移除数据库中的对应记录,无法恢复,请谨慎调用。

接口说明

  • 接口路径:/api/mcp-services/{mcp_id}
  • 请求方法:DELETE
  • 请求参数:
    • 路径参数:mcp_id (int) — 需要删除的MCP服务的唯一标识符
  • 返回数据:{"ok": true} 表示删除成功

典型请求示例

curl --location --request DELETE "http://127.0.0.1:8000/api/mcp-services/1"

典型响应

{
  "ok": true
}

注意事项

  • 若mcp_id对应的服务不存在,API将返回404异常,响应内容为记录不存在的信息。
  • 删除操作会立即生效,无法恢复。
  • 只有有权的用户/系统应调用该接口,以防误删服务数据。

实现思路简述

  • 路由处理器根据mcp_id查询数据库中的服务记录。
  • 若找到,则调用repository的删除方法删除记录。
  • 若未找到,则返回404错误。
  • 删除成功后,响应{"ok": true}。

11.1. mcp_repository.py #

app/repositories/mcp_repository.py

# 导入SQLAlchemy的select
from sqlalchemy import select
# 导入SQLAlchemy的Session对象
from sqlalchemy.orm import Session

# 导入项目中的models模块
from app import models
# 导入项目中的schemas模块
from app import schemas

# 定义创建McpService的函数,接收数据库会话db和待创建数据data,返回新建的McpService对象
def create_mcp_service(session: Session, data: schemas.McpServiceCreate) -> models.McpService:
    # 构造McpService模型对象,strip去除名字首尾空白
    row = models.McpService(
        name=data.name.strip(),#去除名字首尾空白
        description=data.description,#服务描述
        protocol=data.protocol.value,#协议类型
        config=data.config,#配置信息
    )
    # 添加新对象到会话
    session.add(row)
    # 提交事务,将更改保存到数据库
    session.commit()
    # 刷新实例,确保row包含数据库自动生成的字段值
    session.refresh(row)
    # 返回新建的McpService对象
    return row

# 定义获取所有McpService对象的函数,参数为数据库会话db,返回McpService对象列表
def list_mcp_services(session: Session) -> list[models.McpService]:
    # 构造按id倒序排序的查询,获取所有McpService记录
    return list(session.scalars(select(models.McpService).order_by(models.McpService.id.desc())).all())


# 定义一个函数,根据传入的mcp_id从数据库中获取对应的McpService对象
# 参数db为数据库会话对象,mcp_id为服务的主键ID
# 如果找到则返回对应的McpService对象,否则返回None
def get_mcp_service(session: Session, mcp_id: int) -> models.McpService | None:
    # 调用SQLAlchemy的get方法根据主键查询McpService
    return session.get(models.McpService, mcp_id)

# 定义一个函数,通过服务名称从数据库中获取对应的McpService对象
# 参数db为数据库会话对象,name为服务名称
# 如果找到则返回对应的McpService对象,否则返回None
def get_by_name(session: Session, name: str) -> models.McpService | None:
    # 构造查询,根据名称筛选McpService记录,并返回第一条结果
    return session.scalar(select(models.McpService).where(models.McpService.name == name))

# 定义一个函数,更新指定的McpService对象内容
# 参数db为数据库会话对象,row为待更新的McpService对象,data为更新数据
# 返回更新后的McpService对象
def update_mcp_service(session: Session, row: models.McpService, data: schemas.McpServiceUpdate) -> models.McpService:
    # 如果更新数据中name字段不为空,则去除首尾空白后赋值
    if data.name is not None:
        row.name = data.name.strip()
    # 如果更新数据中description字段不为空,则赋值
    if data.description is not None:
        row.description = data.description
    # 如果更新数据中protocol字段不为空,则将其值转为字符串后赋值
    if data.protocol is not None:
        row.protocol = data.protocol.value
    # 如果更新数据中config字段不为空,则赋值
    if data.config is not None:
        row.config = data.config
    # 提交事务,将更改保存到数据库
    session.commit()
    # 刷新实例,确保row包含最新的字段值
    session.refresh(row)
    # 返回更新后的McpService对象
    return row

# 定义删除McpService服务的函数,接收数据库会话db和待删除的McpService对象row,无返回值
+def delete_mcp_service(session: Session, row: models.McpService) -> None:
    # 从数据库会话中删除指定的row对象
+   session.delete(row)
    # 提交删除操作,将更改保存到数据库
+   session.commit()

11.2. mcp_services.py #

app/routers/mcp_services.py

# 引入日志模块
import logging

# 从FastAPI导入路由器、依赖项和HTTP异常类
from fastapi import APIRouter, Depends, HTTPException
# 从SQLAlchemy导入唯一性错误异常
from sqlalchemy.exc import IntegrityError
# 从SQLAlchemy导入ORM会话对象
from sqlalchemy.orm import Session

# 导入应用程序的数据模型
from app import schemas
# 导入mcp_repository模块,包含数据库操作方法
from app.repositories import mcp_repository
# 导入用于获取数据库会话的依赖函数
from app.database import get_session

# 创建API路由器,设置前缀和标签
router = APIRouter(prefix="/api/mcp-services", tags=["mcp-services"])
# 获取当前模块的日志记录器
logger = logging.getLogger(__name__)


# 定义GET方法,用于列出所有已注册的MCP服务,返回值为McpServiceOut的列表
@router.get("", response_model=list[schemas.McpServiceOut])
def list_services(session: Session = Depends(get_session)):
    # 调用mcp_repository中的list_mcp_services方法,查询数据库中的所有服务
    return mcp_repository.list_mcp_services(session)

# 定义POST方法,用于创建一个新的MCP服务,接收McpServiceCreate模式对象作为请求体
@router.post("", response_model=schemas.McpServiceOut)
def create_service(payload: schemas.McpServiceCreate, session: Session = Depends(get_session)):
    try:
        # 调用mcp_repository中的create_mcp_service方法,将新服务信息写入数据库并返回
        return mcp_repository.create_mcp_service(session, payload)
    except IntegrityError:
        # 捕获唯一性约束异常,如服务名称已存在,进行回滚操作
        session.rollback()
        # 抛出HTTP异常,状态码409,表示名称冲突
        raise HTTPException(status_code=409, detail="名称已存在")


# 定义一个GET类型的路由,用于根据mcp_id获取单个MCP服务,返回McpServiceOut模型
@router.get("/{mcp_id}", response_model=schemas.McpServiceOut)
# 处理函数:根据传入的mcp_id和数据库会话读取指定的服务
def get_service(mcp_id: int, session: Session = Depends(get_session)):
    # 调用仓库方法获取对应id的MCP服务记录
    row = mcp_repository.get_mcp_service(session, mcp_id)
    # 如果记录不存在,则抛出404异常
    if not row:
        raise HTTPException(status_code=404, detail="记录不存在")
    # 返回查找到的服务记录
    return row        

# 定义PUT方法用于更新指定ID的MCP服务,返回更新后的服务对象
@router.put("/{mcp_id}", response_model=schemas.McpServiceOut)
# 处理函数,参数包括服务ID、更新请求体、数据库会话
def update_service(mcp_id: int, payload: schemas.McpServiceUpdate, session: Session = Depends(get_session)):
    # 首先根据mcp_id从数据库查找对应的服务记录
    row = mcp_repository.get_mcp_service(session, mcp_id)
    # 如果未找到记录,则抛出404异常提示记录不存在
    if not row:
        raise HTTPException(status_code=404, detail="记录不存在")
    # 如果更新请求中包含新的名称
    if payload.name is not None:
        # 通过名称查找是否存在其他服务
        other = mcp_repository.get_by_name(session, payload.name.strip())
        # 如果找到的其他服务ID与当前更新目标不同,说明名称已被占用
        if other and other.id != mcp_id:
            raise HTTPException(status_code=409, detail="名称已被其他记录使用")
    try:
        # 调用repository方法执行数据库更新并返回结果
        return mcp_repository.update_mcp_service(session, row, payload)
    except IntegrityError:
        # 捕获唯一约束冲突,回滚事务
        session.rollback()
        # 抛出409异常提示名称冲突
        raise HTTPException(status_code=409, detail="名称冲突")    

# 定义DELETE方法的路由,指定路径参数mcp_id
+@router.delete("/{mcp_id}")
# 删除指定ID的MCP服务,db为数据库会话依赖
+def delete_service(mcp_id: int, session: Session = Depends(get_session)):
    # 根据mcp_id查询对应的MCP服务记录
+   row = mcp_repository.get_mcp_service(session, mcp_id)
    # 如果记录不存在,则抛出404异常
+   if not row:
+       raise HTTPException(status_code=404, detail="记录不存在")
    # 调用仓库方法删除该服务记录
+   mcp_repository.delete_mcp_service(session, row)
    # 返回成功标志
+   return {"ok": True}        

11.3 测试 #

curl --location --request DELETE "http://127.0.0.1:8000/api/mcp-services/1"

11. 路线规划服务 #

  • 百度地图路线规划SDK

本部分将介绍如何基于 百度地图路线规划API 搭建并测试自定义的 MCP 路线规划服务及客户端。
主要内容包括:

  • 路线规划服务端(mcp-services/direction-server.py)部署和接口说明
  • 调试客户端(mcp-services/direction-client.py)调用方式
  • API 测试方法示例
  • 关键模型与字段说明

路线规划服务端说明

mcp-services/direction-server.py 是基于 FastMCP 的 MCP 路线规划服务实现。主要功能:

  • 支持通过 MCP 协议进行路线规划(如驾车、公共交通等),调用百度地图 API 实时查询并返回规划结果。
  • 核心接口为 plan_route 工具,入参包含自然语言描述、最大步数等,并可自动提取起点、终点及规划方式。
  • 支持异步运行,面向流式 HTTP 交互,方便对接各类前端和中台应用。

启动方式:

python mcp-services/direction-server.py

服务将默认在 http://127.0.0.1:8002/mcp 上通过 Streamable HTTP MCP 协议提供服务。

请求头要求
所有调用均需提供有效的以下请求头:

  • BAIDU_MAP_AK:你的百度地图开放平台 AK
  • DEEPSEEK_API_KEY:DeepSeek AI Key(用于智能解析自然语言输入)

调试用客户端说明

mcp-services/direction-client.py 提供了与上述服务端配套的异步 HTTP 客户端样例,演示如何通过 Streamable HTTP 协议调用服务:

  • 内置两种调用示例:普通驾车路线、长途公共交通路线
  • 日志实时输出路线结果摘要
  • 可根据实际需求修改 user_input 或 headers 实现自定义调用

运行方式:

python mcp-services/direction-client.py

API 测试示例

你可以直接用 curl 对 MCP 服务进行测试,如下所示:

curl --location --request POST "http://127.0.0.1:8000/api/mcp-services/test" \
--header "Content-Type: application/json" \
--data-raw '{
  "protocol": "streamable-http",
  "config": {
    "url": "http://127.0.0.1:8002/mcp",
    "headers": {
      "BAIDU_MAP_AK": "你的百度地图AK",
      "DEEPSEEK_API_KEY": "你的DeepSeek Key"
    }
  }
}'

其中 AK 和 Key 请替换为你自己申请的有效密钥。

返回结果示例

调用成功时,将以结构化文本或 JSON 返回,包括:

  • 输入参数(如起终点、方式等)
  • 规划总里程与预计时长
  • 导航摘要(分步文字路线说明)

模型与关键参数说明

  • 服务参数、响应模型定义详见主入口 plan_route
  • user_input 支持自然语言路线描述,自动提取目标(例如:“从北京市海淀区到天津市滨海新区驾车,尽量不走高速”)
  • max_steps 可控制分步详细程度,范围 1 ~ 20

注意事项

  • 确保百度地图 AK、DeepSeek API Key 有效且配置正确
  • 勿泄露密钥;生产环境请务必妥善保护敏感配置
  • 一些错误和异常会以结构化方式返回,可根据响应 message 字段进行排查

11.1. mcp_tester.py #

app/services/mcp_tester.py


# 导入异步库asyncio
import asyncio
# 导入自定义的streamable_http_client客户端
from mcp.client.streamable_http import streamable_http_client
# 导入日志库
import logging
# 导入httpx用于网络请求
import httpx
# 从mcp模块导入ClientSession用于会话管理
from mcp import ClientSession
# 导入自定义的mcp_httpx_client_factory工厂函数
from app.services.mcp_httpx import mcp_httpx_client_factory
# 获取logger对象用于日志输出
logger = logging.getLogger(__name__)

# 合并配置中的HTTP请求头
def merge_headers(config):
    # 获取headers配置
    raw = config.get("headers")
    # 如果headers为空或不是字典类型,返回空字典
    if not raw or not isinstance(raw, dict):
        return {}
    # 创建输出字典
    out = {}
    # 将所有键值都转成字符串
    for k, v in raw.items():
        out[str(k)] = str(v)
    # 返回处理后的headers
    return out

# 提取工具列表
def extract_tools(tools_result):
    # 如果结果为None,返回空列表
    if tools_result is None:
        return []
    # 如果本身就是列表,直接返回
    if isinstance(tools_result, list):
        return tools_result
    # 否则尝试从tools_result对象中获取tools属性
    tools = getattr(tools_result, "tools", None)
    # 如果tools属性为列表,返回之
    if isinstance(tools, list):
        return tools
    # 否则返回空列表
    return []

# 归一化工具对象格式
def normalize_tool(tool):
    # 如果tool为None,返回默认空结构
    if tool is None:
        return {"name": "", "description": "", "input_schema": {}}
    # 打印调试信息
    print("name", getattr(tool, "name", ""))
    print("description", getattr(tool, "description", ""))
    print("input_schema", getattr(tool, "inputSchema", {}))
    # 返回规范化后的工具描述
    return {
        "name": getattr(tool, "name", ""),
        "description": getattr(tool, "description", ""),
        "input_schema": getattr(tool, "inputSchema", {}),
    }

# 对指定协议以session方式探测工具列表
async def probe_with_session(read, write, protocol):
    # 使用ClientSession进行会话管理
    async with ClientSession(read, write) as session:
        # 初始化会话
        initialize_result = await session.initialize()
        # 获取工具列表
        tools_result = await session.list_tools()
        # 提取工具对象
        raw_tools = extract_tools(tools_result)
        # 归一化所有工具对象
        tools = [normalize_tool(tool) for tool in raw_tools]
        # 日志输出工具数量
        logger.info("%s 探测到了工具列表的数量为=%s", protocol, len(tools))
        # 获取服务信息
        server_info = getattr(initialize_result, "serverInfo", None)
        # 如果服务信息含有名称字段
        if server_info and getattr(server_info, "name", None):
            return True, f"{protocol}MCP服务初始化成功:{server_info.name}", tools
        # 否则返回通用成功消息
        return True, f"{protocol}MCP服务初始化成功", tools


# 测试 streamable-http 协议的 MCP 服务
def test_streamable_http(config):
    # 从配置中获取 url
    url = config.get("url")
    # 若 url 无效或不是字符串,直接返回错误信息
    if not url or not isinstance(url, str):
        return False, "streamable-http 配置需要字符串 url", []
    # 标准化合并 headers
    headers = merge_headers(config)

    # 定义异步检测函数
    async def _run_http_check():
        try:
            # 使用定制的 httpx async client 工厂函数创建客户端(避免本地代理干扰)
            async with mcp_httpx_client_factory(headers=headers) as http_client:
                # 以异步方式建立与 streamable-http MCP 服务的连接,并获取读写对象
                async with streamable_http_client(url, http_client=http_client) as (read, write, _):
                    # 调用内部探测逻辑初始化 session 并获取工具列表
                    return await probe_with_session(read, write, "streamable-http")
        # 捕获超时异常(如连接或响应过慢),返回特定的提示
        except asyncio.TimeoutError:
            return False, "等待响应超时;请检查 streamable-http 服务地址与请求头", []
        # 捕获 httpx 的请求异常,如网络不可达等
        except httpx.RequestError as e:
            return False, f"请求失败: {e}", []
        # 捕获所有其他异常,写日志,返回简要错误说明
        except Exception as e: 
            logger.exception("streamable-http 检测失败")
            return False, f"streamable-http MCP 检测失败: {e}", []

    # 在主线程执行异步检测函数并返回结果
    return asyncio.run(_run_http_check())

# MCP通用检测入口,根据协议选择检测方法
def test_mcp(protocol, config):
    # 将协议字符串归一化为小写去除空格
    p = (protocol or "").lower().strip()
    # 如果协议是streamable-http或http则用http检测方法
    if p in {"streamable-http", "http"}:
        return test_streamable_http(config)
    # 否则返回不支持的协议
    return False, f"不支持的协议: {protocol}", []

11.2. mcp_httpx.py #

app\services\mcp_httpx.py

# 导入httpx库,用于HTTP请求
import httpx
# 从共享工具模块导入默认的SSE读取超时时间和通用超时时间
from mcp.shared._httpx_utils import MCP_DEFAULT_SSE_READ_TIMEOUT, MCP_DEFAULT_TIMEOUT

# 定义一个工厂函数,生成带有配置的httpx.AsyncClient异步客户端
def mcp_httpx_client_factory(headers=None, timeout=None, auth=None):
    # 初始化配置参数字典
    kwargs = {}
    # 如果未指定timeout参数,则设置为默认超时时间
    if timeout is None:
        # 设置连接和读取操作的超时时间
        kwargs["timeout"] = httpx.Timeout(
            MCP_DEFAULT_TIMEOUT, read=MCP_DEFAULT_SSE_READ_TIMEOUT
        )
    else:
        # 使用用户传入的超时时间
        kwargs["timeout"] = timeout
    # 如果指定了headers参数,则添加到配置中
    if headers is not None:
        kwargs["headers"] = headers
    # 如果指定了auth参数,则添加到配置中
    if auth is not None:
        kwargs["auth"] = auth

    # 返回配置好的httpx异步客户端实例
    return httpx.AsyncClient(**kwargs)

11.3. direction-client.py #

mcp-services/direction-client.py

# 调试用:连接 direction Streamable HTTP MCP 并调用 plan_route。
"""调试用:连接 direction Streamable HTTP MCP 并调用 plan_route。"""

# 导入异步IO库
import asyncio
# 导入日志模块
import logging

# 导入httpx库,用于发起HTTP请求
import httpx
# 导入MCP客户端会话
from mcp import ClientSession
# 导入Streamable HTTP客户端连接方法
from mcp.client.streamable_http import streamable_http_client

# 配置日志输出格式和日志级别
logging.basicConfig(level=logging.INFO, format="%(asctime)s %(levelname)s %(name)s %(message)s")
# 获取一个名为 direction-client 的logger对象
logger = logging.getLogger("direction-client")

# MCP服务HTTP地址
HTTP_URL = "http://127.0.0.1:8002/mcp"
# 百度地图AK
BAIDU_MAP_AK = "51wJobm0zfEyyZv1FGSAGmvrBAXGrqU9"
# DeepSeek API Key
DEEPSEEK_API_KEY = "sk-24088156e9ab48f3adddaf5a9c0c4ede"


# 定义异步主运行函数
async def run():
    # 输出连接流式HTTP服务日志
    logger.info("连接 Streamable HTTP 服务:%s", HTTP_URL)
    # 构造请求头,包含百度地图AK和DeepSeek API Key
    headers = {"BAIDU_MAP_AK": BAIDU_MAP_AK, "DEEPSEEK_API_KEY": DEEPSEEK_API_KEY}
    # 使用httpx异步客户端设置请求头和超时时间
    async with httpx.AsyncClient(headers=headers, timeout=30.0) as http_client:
        # 使用streamable HTTP客户端连接到MCP服务,获取读写接口
        async with streamable_http_client(HTTP_URL, http_client=http_client) as (read, write, _):
            # 新建一个MCP客户端会话
            async with ClientSession(read, write) as session:
                # 初始化MCP会话
                await session.initialize()
                # 日志记录会话初始化成功
                logger.info("MCP 会话初始化成功")

                # 日志记录准备调用plan_route(驾车)
                logger.info("调用 plan_route(驾车)")
                # 调用plan_route工具,参数为驾车路线规划
                driving = await session.call_tool(
                    "plan_route",
                    {"user_input": "从北京市海淀区到天津市滨海新区驾车,尽量不走高速", "max_steps": 6},
                )
                # 遍历返回的内容
                for item in driving.content:
                    # 提取每个返回内容的text字段
                    text = getattr(item, "text", "")
                    # 如果text内容不为空,输出日志
                    if text:
                        logger.info("驾车结果:\n%s", text)

                # 日志记录准备调用plan_route(公共交通)
                logger.info("调用 plan_route(公共交通)")
                # 调用plan_route工具,参数为公交路线规划
                transit = await session.call_tool(
                    "plan_route",
                    {"user_input": "从北京市朝阳区到上海市浦东新区公共交通,优先火车", "max_steps": 6},
                )
                # 遍历返回的内容
                for item in transit.content:
                    # 提取每个返回内容的text字段
                    text = getattr(item, "text", "")
                    # 如果text内容不为空,输出日志
                    if text:
                        logger.info("公共交通结果:\n%s", text)


# 定义主入口函数
def main():
    # 使用asyncio运行主异步任务
    asyncio.run(run())


# 如果作为主程序运行
if __name__ == "__main__":
    # 调用主函数
    main()

11.4. direction-server.py #

mcp-services/direction-server.py

# 路线规划 MCP 服务器(Streamable HTTP)
"""路线规划 MCP 服务器(Streamable HTTP)。"""

# 导入相关标准库
import json
import logging
from typing import Annotated

# 导入第三方库
import httpx
from langchain_core.output_parsers import JsonOutputParser
from langchain_core.prompts import ChatPromptTemplate
from langchain_deepseek import ChatDeepSeek
from mcp.server.fastmcp import Context, FastMCP
from pydantic import Field

# 配置日志输出格式和等级
logging.basicConfig(level=logging.INFO, format="%(asctime)s %(levelname)s %(name)s %(message)s")
# 获取logger对象
logger = logging.getLogger("direction-service")

# 设置服务监听地址和端口
HOST = "127.0.0.1"
PORT = 8002
# 设置HTTP接口路径
HTTP_PATH = "/mcp"
# 创建FastMCP实例
mcp = FastMCP("路线规划服务", host=HOST, port=PORT, streamable_http_path=HTTP_PATH)

# 百度地理编码API地址
GEOCODE_URL = "https://api.map.baidu.com/geocoding/v3/"
# 百度路线规划API基础地址
ROUTE_V2_BASE = "https://api.map.baidu.com/direction/v2"
# DeepSeek 服务基础地址
DEEPSEEK_BASE_URL = "https://api.deepseek.com"
# DeepSeek 使用的模型
DEEPSEEK_MODEL = "deepseek-chat"

# 获取请求头中的指定key的值
def _header_value(ctx, key):
    # 初始化request为None
    request = None
    # 如果上下文和request_context存在
    if ctx and ctx.request_context:
        # 获取request对象
        request = getattr(ctx.request_context, "request", None)
    # 如果request不存在则抛异常
    if request is None:
        raise RuntimeError(f"缺少请求上下文,无法读取请求头 {key}")
    # 读取header中的key或小写key
    raw = request.headers.get(key) or request.headers.get(key.lower()) or ""
    # 去除首尾空白
    v = str(raw).strip()
    # 如果仍为空则抛出异常
    if not v:
        raise RuntimeError(f"缺少请求头 {key}")
    # 返回header值
    return v

# 获取百度地图AK
def _ak(ctx):
    return _header_value(ctx, "BAIDU_MAP_AK")

# 获取DeepSeek会话对象
def _deepseek(ctx):
    # 获取DeepSeek API KEY
    key = _header_value(ctx, "DEEPSEEK_API_KEY")
    # 返回ChatDeepSeek实例
    return ChatDeepSeek(
        api_key=key,
        base_url=DEEPSEEK_BASE_URL,
        model=DEEPSEEK_MODEL,
        temperature=0,
    )

# 抽取用户输入的路线参数
async def _extract_route_args(user_input, ctx):
    # 记录日志,开始参数抽取
    logger.info("开始抽取路线参数,input=%s", user_input)
    # 创建json输出解析器
    parser = JsonOutputParser()
    # 构建语言模型提示模板
    prompt = ChatPromptTemplate.from_template(
        """
        从输入中抽取百度路线规划参数,只输出 JSON:
        {{
        "origin": "",
        "destination": "",
        "mode": "driving",
        "tactics": ""
        }}
        约束:
        1) mode 只能是 driving / transit。
        2) 如果用户提到“公共交通/地铁/公交/高铁/动车/火车/飞机”,mode 设为 transit。
        3) tactics 可选(仅 driving 生效,如 “avoid_highway”),否则空字符串。
        输入:{user_input}
        输出格式要求:{format_instructions}
        """.strip()
    )
    # 组装prompt->模型->json解析链
    chain = prompt | _deepseek(ctx) | parser
    # 用链式方式抽取参数
    data = await chain.ainvoke(
        {"user_input": user_input, "format_instructions": parser.get_format_instructions()}
    )
    # 获取mode,如果不正确则置为driving
    mode = str(data.get("mode") or "driving").strip().lower()
    if mode not in {"driving", "transit"}:
        mode = "driving"
    # 组装抽取后的参数
    extracted = {
        "origin": str(data.get("origin") or "").strip(),# 起点
        "destination": str(data.get("destination") or "").strip(),# 终点
        "mode": mode,# 模式
        "tactics": str(data.get("tactics") or "").strip(),# 策略
    }
    # 打印参数抽取完成日志
    logger.info("参数抽取完成:%s", json.dumps(extracted, ensure_ascii=False))# 打印参数抽取完成日志
    return extracted# 返回参数字典

# 地理编码(地址->经纬度)
async def _geocode(address, ctx):
    # 构造地理编码请求参数
    req = {"address": address, "output": "json", "ak": _ak(ctx)}
    # 创建异步HTTP客户端
    async with httpx.AsyncClient(timeout=20.0) as client:
        # 调用百度地理编码接口
        r = await client.get(GEOCODE_URL, params=req)
        # 检查响应状态码
        r.raise_for_status()
        # 解析返回json
        data = r.json()
    # 检查百度状态码
    if data.get("status") != 0:
        raise RuntimeError(f"地理编码失败: {address}, status={data.get('status')}")
    # 获取返回的位置信息
    result = data.get("result") or {}
    loc = result.get("location") or {}
    lng, lat = loc.get("lng"), loc.get("lat")
    # 无经纬度则抛异常
    if lng is None or lat is None:
        raise RuntimeError(f"地理编码失败: {address}, 无坐标")
    # 返回经纬度字符串和格式化地址
    return f"{lat},{lng}", result.get("formatted_address") or address

# 秒转为小时分钟
def _seconds_to_hhmm(sec):
    # 转换为整数
    sec = int(sec or 0)
    # 小时数
    h = sec // 3600
    # 分钟数
    m = (sec % 3600) // 60
    # 若有小时则返回带小时文本
    if h > 0:
        return f"{h}小时{m}分钟"
    # 否则只返回分钟
    return f"{m}分钟"

# 构建每一步路线的文本描述
def _step_text(step):
    # 优先从多个字段尝试提取文本说明
    text = str(
        step.get("instruction")
        or step.get("instructions")
        or step.get("html_instructions")
        or ""
    ).strip()
    # 有文本则去除标签后返回
    if text:
        return text.replace("<b>", "").replace("</b>", "")

    # 若没有instruction再拼凑道路名和距离等
    road = str(step.get("road_name") or "道路").strip()
    dist = int(step.get("distance") or 0)
    dur = int(step.get("duration") or 0)
    # 距离大于0则打印完整描述
    if dist > 0:
        return f"沿{road}行驶约{dist}米,预计{_seconds_to_hhmm(dur)}"
    # 否则只描述沿路行驶
    return f"沿{road}行驶"

# 查询路线主流程
async def _query_route(route_args, ctx):
    # 地理编码原始起点
    origin_ll, origin_fmt = await _geocode(route_args["origin"], ctx)
    # 地理编码终点
    dest_ll, dest_fmt = await _geocode(route_args["destination"], ctx)
    # 路线方式
    mode = route_args["mode"]
    # 组装百度路线接口地址
    endpoint = f"{ROUTE_V2_BASE}/{mode}"

    # 组装请求参数
    req = {
        "origin": origin_ll,# 起点
        "destination": dest_ll,# 终点
        "ak": _ak(ctx),# 百度地图AK
    }
    # 若为驾车方式,添加tactics参数
    if mode == "driving":
        req["tactics"] = "11" if route_args["tactics"] == "avoid_highway" else "0"# 策略

    # 记录调用路线API的日志
    logger.info("调用路线接口,mode=%s params=%s", mode, json.dumps(req, ensure_ascii=False))# 打印调用路线接口日志
    # 调用百度路线接口
    async with httpx.AsyncClient(timeout=30.0) as client:
        r = await client.get(endpoint, params=req)
        r.raise_for_status()# 检查响应状态码
        data = r.json()# 解析返回json
    # 校验返回状态
    if data.get("status") != 0:
        raise RuntimeError(f"路线规划失败: status={data.get('status')} message={data.get('message')}")# 抛出异常
    # 提取并处理返回的数据
    result = data.get("result") or {}
    routes = list(result.get("routes") or [])# 提取路线列表
    # 没有可用路线则报错
    if not routes:
        raise RuntimeError("路线规划失败: 无可用路线")# 抛出异常
    # 选取最佳路线
    best = routes[0]# 最佳路线
    # 返回路线主要信息
    return {
        "origin": origin_fmt,# 起点
        "destination": dest_fmt,# 终点
        "mode": mode,# 模式
        "distance": int(best.get("distance") or 0),# 距离
        "duration": int(best.get("duration") or 0),# 时长
        "steps": best.get("steps") or [],# 步骤 
    }

# 注册为MCP工具
@mcp.tool()
# 路线规划主接口
async def plan_route(
    # 用户输入的需求
    user_input: Annotated[str, Field(description="用户输入的路线规划需求,例如:从北京市海淀区到天津市滨海新区驾车,尽量不走高速")],
    # 最大步数(可选,默认6,范围1~20)
    max_steps: Annotated[int, Field(description="最大步数,范围 1~20,默认 6")] = 6,
    # 上下文参数
    ctx: Annotated[Context, Field(description="上下文")] = None,
):
    # 路线规划服务文档字符串
    """路线规划服务。"""
    # 抽取路线参数
    route_args = await _extract_route_args(user_input, ctx)
    # 获取路线结果
    data = await _query_route(route_args, ctx)
    # 限制最大步数
    max_steps = max(1, min(int(max_steps), 20))

    # 构建返回描述文本
    lines = [
        f"输入:{user_input}",
        f"参数:{json.dumps(route_args, ensure_ascii=False)}",
        f"出发地:{data['origin']}",
        f"目的地:{data['destination']}",
        f"方式:{data['mode']}",
        f"总里程:{round(data['distance'] / 1000, 2)} 公里",
        f"预计时长:{_seconds_to_hhmm(data['duration'])}",
        "",
        "导航摘要:",
    ]
    # 步数已展示计数器
    shown = 0
    # 遍历每个路线步骤
    for step in data["steps"]:
        # 达到最多步数则结束
        if shown >= max_steps:
            break
        # 某些方案一步为嵌套列表
        if isinstance(step, list):
            # 遍历内部每一个
            for s in step:
                if shown >= max_steps:
                    break
                lines.append(f"- {_step_text(s)}")
                shown += 1
        else:
            lines.append(f"- {_step_text(step)}")
            shown += 1

    # 组合所有文本并返回
    return "\n".join(lines)

# 启动入口
def main():
    # 打印启动日志
    logger.info("启动 Direction MCP Streamable HTTP 服务: http://%s:%s%s", HOST, PORT, HTTP_PATH)
    # 启动MCP服务
    mcp.run(transport="streamable-http")

# 如果作为主程序执行
if __name__ == "__main__":
    main()

11.5. mcp_services.py #

app/routers/mcp_services.py

# 引入日志模块
import logging

# 从FastAPI导入路由器、依赖项和HTTP异常类
from fastapi import APIRouter, Depends, HTTPException
# 从SQLAlchemy导入唯一性错误异常
from sqlalchemy.exc import IntegrityError
# 从SQLAlchemy导入ORM会话对象
from sqlalchemy.orm import Session

# 导入应用程序的数据模型
from app import schemas
# 导入mcp_repository模块,包含数据库操作方法
from app.repositories import mcp_repository
# 导入用于获取数据库会话的依赖函数
from app.database import get_session
# 导入mcp_tester模块,包含测试MCP服务的方法
+from app.services.mcp_tester import test_mcp
# 创建API路由器,设置前缀和标签
router = APIRouter(prefix="/api/mcp-services", tags=["mcp-services"])
# 获取当前模块的日志记录器
logger = logging.getLogger(__name__)


# 定义GET方法,用于列出所有已注册的MCP服务,返回值为McpServiceOut的列表
@router.get("", response_model=list[schemas.McpServiceOut])
def list_services(session: Session = Depends(get_session)):
    # 调用mcp_repository中的list_mcp_services方法,查询数据库中的所有服务
    return mcp_repository.list_mcp_services(session)

# 定义POST方法,用于创建一个新的MCP服务,接收McpServiceCreate模式对象作为请求体
@router.post("", response_model=schemas.McpServiceOut)
def create_service(payload: schemas.McpServiceCreate, session: Session = Depends(get_session)):
    try:
        # 调用mcp_repository中的create_mcp_service方法,将新服务信息写入数据库并返回
        return mcp_repository.create_mcp_service(session, payload)
    except IntegrityError:
        # 捕获唯一性约束异常,如服务名称已存在,进行回滚操作
        session.rollback()
        # 抛出HTTP异常,状态码409,表示名称冲突
        raise HTTPException(status_code=409, detail="名称已存在")


# 定义一个GET类型的路由,用于根据mcp_id获取单个MCP服务,返回McpServiceOut模型
@router.get("/{mcp_id}", response_model=schemas.McpServiceOut)
# 处理函数:根据传入的mcp_id和数据库会话读取指定的服务
def get_service(mcp_id: int, session: Session = Depends(get_session)):
    # 调用仓库方法获取对应id的MCP服务记录
    row = mcp_repository.get_mcp_service(session, mcp_id)
    # 如果记录不存在,则抛出404异常
    if not row:
        raise HTTPException(status_code=404, detail="记录不存在")
    # 返回查找到的服务记录
    return row        

# 定义PUT方法用于更新指定ID的MCP服务,返回更新后的服务对象
@router.put("/{mcp_id}", response_model=schemas.McpServiceOut)
# 处理函数,参数包括服务ID、更新请求体、数据库会话
def update_service(mcp_id: int, payload: schemas.McpServiceUpdate, session: Session = Depends(get_session)):
    # 首先根据mcp_id从数据库查找对应的服务记录
    row = mcp_repository.get_mcp_service(session, mcp_id)
    # 如果未找到记录,则抛出404异常提示记录不存在
    if not row:
        raise HTTPException(status_code=404, detail="记录不存在")
    # 如果更新请求中包含新的名称
    if payload.name is not None:
        # 通过名称查找是否存在其他服务
        other = mcp_repository.get_by_name(session, payload.name.strip())
        # 如果找到的其他服务ID与当前更新目标不同,说明名称已被占用
        if other and other.id != mcp_id:
            raise HTTPException(status_code=409, detail="名称已被其他记录使用")
    try:
        # 调用repository方法执行数据库更新并返回结果
        return mcp_repository.update_mcp_service(session, row, payload)
    except IntegrityError:
        # 捕获唯一约束冲突,回滚事务
        session.rollback()
        # 抛出409异常提示名称冲突
        raise HTTPException(status_code=409, detail="名称冲突")    

# 定义DELETE方法的路由,指定路径参数mcp_id
@router.delete("/{mcp_id}")
# 删除指定ID的MCP服务,db为数据库会话依赖
def delete_service(mcp_id: int, session: Session = Depends(get_session)):
    # 根据mcp_id查询对应的MCP服务记录
    row = mcp_repository.get_mcp_service(session, mcp_id)
    # 如果记录不存在,则抛出404异常
    if not row:
        raise HTTPException(status_code=404, detail="记录不存在")
    # 调用仓库方法删除该服务记录
    mcp_repository.delete_mcp_service(session, row)
    # 返回成功标志
    return {"ok": True}        


# 定义POST接口,路径为/test,响应模型为McpTestResult
+@router.post("/test", response_model=schemas.McpTestResult)
# 定义处理函数,接收McpTestRequest为请求体
+def test_service(payload: schemas.McpTestRequest):
    # 调用test_mcp函数,传入协议类型和配置,获取测试结果、消息和工具列表
+   ok, msg, tools = test_mcp(payload.protocol.value, payload.config)
    # 记录日志,包括协议类型、测试是否成功和工具数量
+   logger.info("mcp test protocol=%s ok=%s tools=%s", payload.protocol.value, ok, len(tools))
    # 返回McpTestResult对象,包含测试结果、消息和工具列表
+   return schemas.McpTestResult(ok=ok, message=msg, tools=tools)    

11.6. schemas.py #

app/schemas.py


# 导入枚举类型
from enum import Enum
# 导入Any类型用于类型注解
from typing import Any

# 从pydantic导入BaseModel、Field和field_validator用于数据验证
from pydantic import BaseModel, Field, field_validator

# 定义MCP协议类型的枚举类
class McpProtocol(str, Enum):
    # MCP 协议类型注释
    """MCP 协议类型"""
    # stdio协议
    stdio = "stdio"
    # streamable-http协议
    streamable_http = "streamable-http"
    # sse协议
    sse = "sse"

# 定义MCP服务基础模型
class McpServiceBase(BaseModel):
    # MCP 服务基础模型注释
    """MCP 服务基础模型"""
    # 服务名称,字符串类型,长度1-255
    name: str = Field(..., min_length=1, max_length=255)
    # 服务描述,可选字段,字符串或None
    description: str | None = None
    # 协议类型,使用McpProtocol枚举
    protocol: McpProtocol
    # 配置信息,要求为字典类型
    config: dict[str, Any]

    # 对config字段添加验证器,确保其为字典类型
    @field_validator("config", mode="before")
    @classmethod
    def config_not_empty(cls, v: Any) -> Any:
        # 验证 config 是否为 JSON 对象,如果不是字典则抛出异常
        """验证 config 是否为 JSON 对象"""
        if not isinstance(v, dict):
            raise ValueError("config 必须为 JSON 对象")
        return v

# 定义MCP服务创建模型,继承自McpServiceBase
class McpServiceCreate(McpServiceBase):
    # 不增加额外内容,直接继承
    pass

# 定义MCP服务输出模型
class McpServiceOut(BaseModel):
    # ID字段,整型
    id: int
    # 服务名称
    name: str
    # 服务描述,可选字段
    description: str | None
    # 协议类型,字符串
    protocol: str
    # 配置信息,字典类型
    config: dict[str, Any]

    # 设置模型配置,允许从ORM对象属性读取数据
    model_config = {"from_attributes": True}

# 定义McpServiceUpdate模型,用于更新MCP服务,支持部分字段可选更新
class McpServiceUpdate(BaseModel):
    # 服务名称,允许为None,最小长度1,最大长度255
    name: str | None = Field(None, min_length=1, max_length=255)
    # 服务描述字段,允许为None
    description: str | None = None
    # 协议类型,使用McpProtocol枚举,允许为None
    protocol: McpProtocol | None = None
    # 配置信息,允许为None,类型为字典
    config: dict[str, Any] | None = None    

# 定义McpTestRequest模型,继承自BaseModel
+class McpTestRequest(BaseModel):
    # 协议类型,使用McpProtocol枚举
+   protocol: McpProtocol
    # 配置信息,要求为字典类型
+   config: dict[str, Any]

    # 对config字段添加验证器,在赋值前进行校验
+   @field_validator("config", mode="before")
+   @classmethod
+   def config_is_object(cls, v: Any) -> Any:
        # 如果config不是字典类型,则抛出异常
+       if not isinstance(v, dict):
+           raise ValueError("config 必须为 JSON 对象")
        # 返回config原值
+       return v

# 定义McpTestResult模型,继承自BaseModel
+class McpTestResult(BaseModel):
    # 测试是否成功的标志
+   ok: bool
    # 返回的信息或说明
+   message: str
    # 工具列表,默认为空列表
+   tools: list[dict[str, Any]] = Field(default_factory=list)

11.7 测试 #

curl --location --request POST "http://127.0.0.1:8000/api/mcp-services/test" ^
--header "Content-Type: application/json" ^
--data-raw "{  \"protocol\": \"streamable-http\",  \"config\": {    \"url\": \"http://127.0.0.1:8002/mcp\",    \"headers\": {      \"BAIDU_MAP_AK\": \"51wJobm0zfEyyZv1FGSAGmvrBAXGrqU9\",      \"DEEPSEEK_API_KEY\": \"sk-24088156e9ab48f3adddaf5a9c0c4ede\"    }  }}"

11.8 路线规划服务 #

11.8.1 描述 #

基于 MCP 的智能路线规划服务,采用 Streamable HTTP 协议对外提供能力。服务可将自然语言出行需求自动解析为结构化参数,调用百度地图路线规划接口生成结果,当前支持自驾(driving)与公共交通(transit,含地铁/公交/高铁/动车/飞机场景)。返回内容包含出发地、目的地、总里程、预计时长与导航摘要步骤,适合在 AI 助手中用于行程建议与路线查询。

11.8.2 协议 #

streamable-http

11.8.3 URL #

http://127.0.0.1:8002/mcp

11.8.4 请求头 #

{
  "BAIDU_MAP_AK": "51wJobm0zfEyyZv1FGSAGmvrBAXGrqU9",
  "DEEPSEEK_API_KEY": "sk-24088156e9ab48f3adddaf5a9c0c4ede"
}

11.9 时序图 #

sequenceDiagram autonumber participant Caller as 调用方 participant TM as test_mcp participant TSH as test_streamable_http participant MH as merge_headers participant AR as asyncio participant RHC as run_http_check participant HF as httpx_factory participant SHC as streamable_http_client participant Remote as 远端MCP_HTTP participant PWS as probe_with_session participant CS as ClientSession Caller->>TM: 传入 protocol 与 config TM->>TSH: test_streamable_http config Note over TSH: 同步阶段 读取并校验 url alt url 无效或不是字符串 TSH-->>Caller: 失败 需要字符串 url 空工具列表 else url 有效 TSH->>MH: 合并 config 中的请求头为字符串键值 MH-->>TSH: headers 字典 Note over TSH,AR: 异步阶段 由 asyncio.run 驱动事件循环 TSH->>AR: asyncio.run run_http_check AR->>RHC: 执行内部协程 RHC->>HF: 创建 httpx AsyncClient 传入 headers Note over HF: trust_env 为 false 避免本机代理干扰 HF-->>RHC: http_client RHC->>SHC: 绑定 url 与 http_client 建立传输 SHC->>Remote: streamable HTTP MCP 会话 Remote-->>SHC: 双向消息通道 SHC-->>RHC: read write 与第三个占位返回值 RHC->>PWS: 用 read write 探测 MCP 服务能力 PWS->>CS: ClientSession 绑定传输 CS->>Remote: initialize 初始化 单次超时 15 秒 Remote-->>CS: 服务端信息与能力 loop 最多尝试三次 list_tools CS->>Remote: 列出工具 Remote-->>CS: 工具结果 opt 工具列表仍为空且还可重试 PWS->>AR: 等待 0.2 秒再试 end end PWS->>PWS: 提取并规范化工具 name 描述 schema PWS-->>RHC: 成功 提示文案 工具列表 RHC-->>SHC: 关闭 streamable HTTP 上下文 RHC-->>HF: 关闭 httpx 客户端 RHC-->>AR: 三元组结果 AR-->>TSH: 三元组结果 TSH-->>Caller: 成功或失败 消息 工具数组 end

12. 地点检索服务 #

  • 百度地图地点检索SDK

本节详细介绍“地点检索服务”的功能特性、适用场景、接口协议,以及如何集成和调用。

服务简介

地点检索服务基于 MCP 的工具协议实现,能够将自然语言表达的地点需求转为结构化检索参数,并调用百度地图的 Place API 获取相关的地点列表,如景点、酒店等。该服务特别面向中文出行与旅游等场景:

  • 用户可输入如“北京景点”“上海酒店”这类自然语句;
  • 服务会自动抽取所需的检索参数(如地区、类型、关键词),并返回包含名称、地址、经纬度等信息的地点列表;
  • 支持分页、补充详情(如电话、人均、营业时间等)和无结果时的智能建议。

适用于 AI 助手、智能对话、旅游产品推荐等类型的应用程序。

服务接入方式

  • 协议:使用 Server-Sent Events (sse)
  • 服务地址:http://127.0.0.1:8001/sse
  • 认证参数(放在 headers):
    • BAIDU_MAP_AK:百度地图开放平台申请的密钥
    • DEEPSEEK_API_KEY:DeepSeek API Key

示例 headers:

{
  "BAIDU_MAP_AK": "51wJobm0zfEyyZv1FGSAGmvrBAXGrqU9",
  "DEEPSEEK_API_KEY": "sk-24088156e9ab48f3adddaf5a9c0c4ede"
}

调用流程示例

用户调用流程如下:

  1. 客户端通过 SSE 协议建立长连接,携带有效 Headers。
  2. 通过 MCP 协议的 initialize 进行会话初始化。
  3. 使用 call_tool,调用工具 search_place,并传入参数:
    • 地区 (region)
    • 关键词 (query)
    • 标签(可选)(如“景点”, “酒店”)
    • 是否仅限本地(可选)
    • 分页参数(可选)

调用参数示例:

{
  "tool": "search_place",
  "input": {
    "region": "北京",
    "query": "景点",
    "tags": ["5A级", "博物馆"],
    "strict_local": false,
    "page_num": 1,
    "page_size": 10
  }
}

返回结果说明

  • 返回为地点列表,每一项包含名称、简要描述、地址、经纬度等关键信息。
  • 可根据实际需要为指定地点补充详情字段,如电话、营业时间、评分等。
  • 若本轮输入无结果,将自动返回智能联想词建议,辅助用户调整查询。

快速测试方法

可使用如下命令在本地完成 HTTP 接口测试(注意更换为真实的服务地址和有效的 headers):

curl --location --request POST "http://127.0.0.1:8000/api/mcp-services/test" ^
--header "Content-Type: application/json" ^
--data-raw "{  \"protocol\": \"sse\",  \"config\": {    \"url\": \"http://127.0.0.1:8001/sse\",    \"headers\": {      \"BAIDU_MAP_AK\": \"51wJobm0zfEyyZv1FGSAGmvrBAXGrqU9\",      \"DEEPSEEK_API_KEY\": \"sk-24088156e9ab48f3adddaf5a9c0c4ede\"    }  }}"

服务将返回测试结果、初始化说明及支持的工具描述,便于开发时验证部署环境和接口配置。

12.5 深入集成示例 #

你可以参考 mcp-services/place-client.py,使用 Python 代码通过 sse_client 和 ClientSession 接入 MCP 服务并发起检索请求,实现自动化集成与演示。详细代码见下方 12.1 节。

12.1. place-client.py #

mcp-services/place-client.py

# 调试用:连接 place SSE MCP 并调用 search_place。
"""调试用:连接 place SSE MCP 并调用 search_place。"""

# 导入异步IO模块
import asyncio
# 导入日志模块
import logging

# 从mcp模块导入ClientSession
from mcp import ClientSession
# 从mcp.client.sse模块导入sse_client
from mcp.client.sse import sse_client

# 配置日志格式及级别
logging.basicConfig(level=logging.INFO, format="%(asctime)s %(levelname)s %(name)s %(message)s")
# 创建名为"place-client"的日志记录器
logger = logging.getLogger("place-client")

# SSE服务url
SSE_URL = "http://127.0.0.1:8001/sse"
# 百度地图AK
BAIDU_MAP_AK = "51wJobm0zfEyyZv1FGSAGmvrBAXGrqU9"
# DeepSeek API KEY
DEEPSEEK_API_KEY = "sk-24088156e9ab48f3adddaf5a9c0c4ede"


# 定义异步运行主流程的函数
async def run():
    # 输出正在连接SSE服务的信息
    logger.info("连接 SSE 服务:%s", SSE_URL)
    # 构造请求头,包含AK和API KEY
    headers = {"BAIDU_MAP_AK": BAIDU_MAP_AK, "DEEPSEEK_API_KEY": DEEPSEEK_API_KEY}
    # 异步连接至SSE服务,获取读写流
    async with sse_client(SSE_URL, headers=headers) as (read, write):
        # 使用ClientSession管理MCP会话
        async with ClientSession(read, write) as session:
            # 初始化MCP会话
            await session.initialize()
            # 输出会话初始化成功的信息
            logger.info("MCP 会话初始化成功")

            # 输出将调用search_place工具(查询景点)的信息
            logger.info("调用工具 search_place(景点)")
            # 调用search_place工具,查询北京景点,分页参数设定
            scenic = await session.call_tool(
                "search_place",
                {"user_input": "北京市 景点", "page_size": 5, "page_num": 0, "with_detail": False},
            )
            # 如果响应包含错误,抛出异常
            if scenic.isError:
                raise RuntimeError(str(scenic.content))
            # 输出景点查询成功的信息
            logger.info("景点查询成功")
            # 遍历返回的景点结果,输出描述文本
            for item in scenic.content:
                text = getattr(item, "text", "")
                if text:
                    logger.info("景点结果:\n%s", text)

            # 输出将调用search_place工具(查询酒店)的信息
            logger.info("调用工具 search_place(酒店)")
            # 调用search_place工具,查询北京酒店,要求带详细信息
            hotel = await session.call_tool(
                "search_place",
                {"user_input": "北京市 酒店", "page_size": 5, "page_num": 0, "with_detail": True},
            )
            # 如果响应包含错误,抛出异常
            if hotel.isError:
                raise RuntimeError(str(hotel.content))
            # 输出酒店查询成功的信息
            logger.info("酒店查询成功")
            # 遍历返回的酒店结果,输出描述文本
            for item in hotel.content:
                text = getattr(item, "text", "")
                if text:
                    logger.info("酒店结果:\n%s", text)


# 定义程序入口函数
def main():
    # 运行异步事件循环,启动run函数
    asyncio.run(run())


# 判断当前脚本是否为主模块运行
if __name__ == "__main__":
    # 调用主函数
    main()

12.2. place-server.py #

mcp-services/place-server.py

"""地点检索 MCP 服务器(SSE)。"""

# 导入json用于处理JSON格式数据
import json
# 导入logging用于日志记录
import logging
# 导入os用于获取环境变量
import os
# 导入类型标注工具
from typing import Annotated

# 导入httpx异步HTTP客户端
import httpx
# 导入LangChain的JSON输出解析器
from langchain_core.output_parsers import JsonOutputParser
# 导入LangChain聊天提示模板
from langchain_core.prompts import ChatPromptTemplate
# 导入DeepSeek聊天模型
from langchain_deepseek import ChatDeepSeek
# 导入FastMCP相关
from mcp.server.fastmcp import Context, FastMCP
# 导入pydantic字段定义
from pydantic import Field

# 配置日志输出格式和级别
logging.basicConfig(level=logging.INFO, format="%(asctime)s %(levelname)s %(name)s %(message)s")
# 获取logger实例
logger = logging.getLogger("place-service")

# 设置服务主机地址
HOST = "127.0.0.1"
# 设置服务端口号
PORT = 8001
# 设置SSE路径
SSE_PATH = "/sse"
# 实例化FastMCP服务
mcp = FastMCP("地点检索服务", host=HOST, port=PORT, sse_path=SSE_PATH)

# 百度地图地点检索API地址
PLACE_SEARCH_URL = "https://api.map.baidu.com/place/v2/search"
# 百度地图地点详情API地址
PLACE_DETAIL_URL = "https://api.map.baidu.com/place/v2/detail"
# 百度地图建议词/补全API地址
PLACE_SUGGESTION_URL = "https://api.map.baidu.com/place/v2/suggestion"


# 获取请求头指定key的值(用于提取AK和API KEY)
def _header_value(ctx, key):
    # 初始化request对象
    request = None
    # 检查上下文中是否有request_context,并获取request
    if ctx and ctx.request_context:
        request = getattr(ctx.request_context, "request", None)
    # 如无request,抛异常
    if request is None:
        raise RuntimeError(f"缺少请求上下文,无法读取请求头 {key}")
    # 获取请求头中的key值,区分大小写
    raw = request.headers.get(key) or request.headers.get(key.lower()) or ""
    # 转成字符串并去除首尾空白
    v = str(raw).strip()
    # 如果值为空,抛出异常
    if not v:
        raise RuntimeError(f"缺少请求头 {key}")
    # 返回请求头值
    return v

# 获取百度地图AK(从请求头)
def _ak(ctx):
    return _header_value(ctx, "BAIDU_MAP_AK")

# 获取DeepSeek模型实例(自动从请求头和环境变量读取base_url/model)
def _deepseek(ctx):
    key = _header_value(ctx, "DEEPSEEK_API_KEY")
    return ChatDeepSeek(
        api_key=key,
        base_url=(os.environ.get("DEEPSEEK_BASE_URL") or "https://api.deepseek.com").strip(),
        model=(os.environ.get("DEEPSEEK_MODEL") or "deepseek-chat").strip(),
        temperature=0,
    )

# 异步抽取用户输入中的地点检索参数
async def _extract_place_args(user_input, ctx):
    # 记录日志:开始参数抽取
    logger.info("开始抽取地点检索参数,input=%s", user_input)
    # 实例化JSON解析器
    parser = JsonOutputParser()
    # 构造聊天提示模板
    prompt = ChatPromptTemplate.from_template(
        """
        从用户输入中抽取百度地点检索参数,只输出 JSON:
        {{
        "region": "",
        "query": "",
        "tag": "",
        "city_limit": false
        }}
        规则:
        1) region 必须是地区名(市/州/区县),如“延边朝鲜族自治州”。
        2) query 填检索词,如“景点”“酒店”“火锅”等。
        3) tag 可选(如“旅游景点”“酒店”),无法确定可空字符串。
        4) city_limit 仅当用户强调“仅本地区”时设为 true。
        输入:{user_input}
        输出格式要求:{format_instructions}
        """.strip()
    )
    # 组装prompt、模型和解析器
    chain = prompt | _deepseek(ctx) | parser
    # 异步执行链,获得抽取结果
    data = await chain.ainvoke(
        {"user_input": user_input, "format_instructions": parser.get_format_instructions()}
    )
    # 整合并规范化抽取结果
    extracted = {
        "region": str(data.get("region") or "").strip(),
        "query": str(data.get("query") or "").strip(),
        "tag": str(data.get("tag") or "").strip(),
        "city_limit": bool(data.get("city_limit") or False),
    }
    # 记录日志:抽取结果
    logger.info("参数抽取完成:%s", json.dumps(extracted, ensure_ascii=False))
    # 返回参数字典
    return extracted

# 异步调用百度地点检索API
async def _search_places(params_from_llm, page_size, page_num, ctx):
    # 构造API请求参数
    req = {
        "query": params_from_llm["query"] or "景点",
        "region": params_from_llm["region"],
        "tag": params_from_llm["tag"],
        "city_limit": "true" if params_from_llm["city_limit"] else "false",
        "output": "json",
        "scope": "2",
        "page_size": str(page_size),
        "page_num": str(page_num),
        "ak": _ak(ctx),
    }
    # 如果无region,抛异常
    if not req["region"]:
        raise RuntimeError("LLM 未提取出 region,无法检索地点")

    # 记录日志:将要调用百度API
    logger.info("调用地点检索接口,params=%s", json.dumps(req, ensure_ascii=False))
    # 使用httpx进行异步HTTP请求
    async with httpx.AsyncClient(timeout=30.0) as client:
        r = await client.get(PLACE_SEARCH_URL, params=req)
        r.raise_for_status()
        data = r.json()
    # 如果 API 返回非0状态,表示错误
    if data.get("status") != 0:
        raise RuntimeError(f"地点检索失败: status={data.get('status')} message={data.get('message')}")
    # 返回API响应
    return data

# 根据uid查询地点详细信息
async def _detail_by_uid(uid, ctx):
    # 构造请求参数
    req = {"uid": uid, "scope": "2", "output": "json", "ak": _ak(ctx)}
    # 发送Http请求,获取详情
    async with httpx.AsyncClient(timeout=30.0) as client:
        r = await client.get(PLACE_DETAIL_URL, params=req)
        r.raise_for_status()
        data = r.json()
    # 如请求失败,返回空
    if data.get("status") != 0:
        return {}
    # 返回结果字段
    return data.get("result") or {}

# 根据region和query给出建议词/模糊补全
async def _suggest_region_keyword(region, query, ctx):
    # 构造请求参数
    req = {
        "query": query or region,
        "region": region or "全国",
        "city_limit": "false",
        "output": "json",
        "ak": _ak(ctx),
    }
    # 调用建议词接口
    async with httpx.AsyncClient(timeout=30.0) as client:
        r = await client.get(PLACE_SUGGESTION_URL, params=req)
        r.raise_for_status()
        data = r.json()
    # 如失败,返回空列表
    if data.get("status") != 0:
        return []
    # 返回建议结果列表
    return list(data.get("results") or [])

# 注册为MCP工具
@mcp.tool()
# 定义search_place主函数
async def search_place(
    user_input: Annotated[
        str,
        Field(description="用户输入的地点检索需求,例如:北京 景点"),
    ],
    page_size: Annotated[
        int,
        Field(description="每页数量,范围 1~20,默认 5"),
    ] = 5,
    page_num: Annotated[
        int,
        Field(description="页码,从 0 开始,默认 0"),
    ] = 0,
    with_detail: Annotated[
        bool,
        Field(description="是否补充地点详情信息(电话、人均、营业时间)"),
    ] = False,
    ctx: Context = None,
):
    # 文档注释:地点检索 MCP 服务
    """地点检索 MCP 服务。"""
    # 限定page_size范围
    page_size = max(1, min(int(page_size), 20))
    # 限定page_num不小于0
    page_num = max(0, int(page_num))
    # 转换with_detail为布尔型
    with_detail = bool(with_detail)

    # 调用LLM参数抽取
    params_from_llm = await _extract_place_args(user_input, ctx)
    # 调用检索API获取数据
    data = await _search_places(params_from_llm, page_size, page_num, ctx)
    # 获取检索结果
    results = list(data.get("results") or [])
    # 获取总条数
    total = int(data.get("total") or 0)

    # 组装输出行
    lines = [
        f"输入:{user_input}",
        f"参数:{json.dumps(params_from_llm, ensure_ascii=False)}",
        f"总数:{total},当前页数量:{len(results)}",
        "",
    ]

    # 如果没有检索到结果
    if not results:
        # 获取建议词(如无结果)
        suggestions = await _suggest_region_keyword(params_from_llm["region"], params_from_llm["query"], ctx)
        # 输出未检索到提示
        lines.append("未检索到结果。可参考候选词:")
        # 前5条建议逐条加入
        for i, it in enumerate(suggestions[:5], 1):
            lines.append(f"{i}. {it.get('name', '')} {it.get('city', '')}{it.get('district', '')}")
        # 返回拼接后的文本
        return "\n".join(lines)

    # 对每个结果进行格式化输出
    for i, item in enumerate(results, 1):
        lines.append(f"{i}. {item.get('name', '')}")
        lines.append(f"   地址:{item.get('address', '未知')}")
        lines.append(f"   区域:{item.get('province', '')}{item.get('city', '')}{item.get('area', '')}")
        lines.append(f"   评分:{item.get('detail_info', {}).get('overall_rating', '无')}")
        lines.append(f"   类型:{item.get('detail_info', {}).get('tag', '无')}")
        # 如需补充详情,调用详情接口
        if with_detail:
            uid = str(item.get("uid") or "").strip()
            if uid:
                detail = await _detail_by_uid(uid, ctx)
                d = detail.get("detail_info") or {}
                lines.append(f"   电话:{detail.get('telephone', '无')}")
                lines.append(f"   人均:{d.get('price', '无')}")
                lines.append(f"   营业时间:{d.get('shop_hours', '无')}")
        # 加空行分隔
        lines.append("")

    # 返回最终整合文本
    return "\n".join(lines)

# 主函数,启动MCP SSE服务
def main():
    # 输出服务启动日志
    logger.info("启动 Place MCP SSE 服务: http://%s:%s%s", HOST, PORT, SSE_PATH)
    # 启动MCP SSE服务
    mcp.run(transport="sse")

# 作为主程序入口时执行main方法
if __name__ == "__main__":
    main()

12.3. mcp_tester.py #

app/services/mcp_tester.py


# 导入异步库asyncio
import asyncio
# 导入自定义的streamable_http_client客户端
from mcp.client.streamable_http import streamable_http_client
# 导入日志库
import logging
# 导入httpx用于网络请求
import httpx
# 从mcp模块导入ClientSession用于会话管理
from mcp import ClientSession
# 导入自定义的mcp_httpx_client_factory工厂函数
from app.services.mcp_httpx import mcp_httpx_client_factory
# 导入sse_client客户端
+from mcp.client.sse import sse_client
# 获取logger对象用于日志输出
logger = logging.getLogger(__name__)

# 合并headers辅助函数,将配置中的header全部转换为字符串格式
def merge_headers(config):
    # 获取headers配置
    raw = config.get("headers")
    # 如果headers为空或不是字典类型,返回空字典
    if not raw or not isinstance(raw, dict):
        return {}
    # 创建输出字典
    out = {}
    # 将所有键值都转成字符串
    for k, v in raw.items():
        out[str(k)] = str(v)
    # 返回处理后的headers
    return out

# 提取工具列表
def extract_tools(tools_result):
    # 如果结果为None,返回空列表
    if tools_result is None:
        return []
    # 如果本身就是列表,直接返回
    if isinstance(tools_result, list):
        return tools_result
    # 否则尝试从tools_result对象中获取tools属性
    tools = getattr(tools_result, "tools", None)
    # 如果tools属性为列表,返回之
    if isinstance(tools, list):
        return tools
    # 否则返回空列表
    return []

# 标准化tool对象,确保返回字典结构{name, description, input_schema}
def normalize_tool(tool):
    # 如果tool为None,返回默认空结构
    if tool is None:
        return {"name": "", "description": "", "input_schema": {}}
    # 打印调试信息
    print("name", getattr(tool, "name", ""))
    print("description", getattr(tool, "description", ""))
    print("input_schema", getattr(tool, "inputSchema", {}))
    # 返回规范化后的工具描述
    return {
        "name": getattr(tool, "name", ""),
        "description": getattr(tool, "description", ""),
        "input_schema": getattr(tool, "inputSchema", {}),
    }

# 通过给定的read/write对象与MCP服务初始化,并探测tools列表
async def probe_with_session(read, write, protocol):
    # 使用ClientSession进行会话管理
    async with ClientSession(read, write) as session:
        # 初始化会话
        initialize_result = await session.initialize()
        # 获取工具列表
        tools_result = await session.list_tools()
        # 提取工具对象
        raw_tools = extract_tools(tools_result)
        # 归一化所有工具对象
        tools = [normalize_tool(tool) for tool in raw_tools]
        # 日志输出工具数量
        logger.info("%s 探测到了工具列表的数量为=%s", protocol, len(tools))
        # 获取服务信息
        server_info = getattr(initialize_result, "serverInfo", None)
        # 如果服务信息含有名称字段
        if server_info and getattr(server_info, "name", None):
            return True, f"{protocol}MCP服务初始化成功:{server_info.name}", tools
        # 否则返回通用成功消息
        return True, f"{protocol}MCP服务初始化成功", tools

# 测试 streamable-http 协议的 MCP 服务
def test_streamable_http(config):
    # 从配置中获取 url
    url = config.get("url")
    # 若 url 无效或不是字符串,直接返回错误信息
    if not url or not isinstance(url, str):
        return False, "streamable-http 配置需要字符串 url", []
    # 标准化合并 headers
    headers = merge_headers(config)

    # 定义异步检测函数
    async def _run_http_check():
        try:
            # 使用定制的 httpx async client 工厂函数创建客户端(避免本地代理干扰)
            async with mcp_httpx_client_factory(headers=headers) as http_client:
                # 以异步方式建立与 streamable-http MCP 服务的连接,并获取读写对象
                async with streamable_http_client(url, http_client=http_client) as (read, write, _):
                    # 调用内部探测逻辑初始化 session 并获取工具列表
                    return await probe_with_session(read, write, "streamable-http")
        # 捕获超时异常(如连接或响应过慢),返回特定的提示
        except asyncio.TimeoutError:
            return False, "等待响应超时;请检查 streamable-http 服务地址与请求头", []
        # 捕获 httpx 的请求异常,如网络不可达等
        except httpx.RequestError as e:
            return False, f"请求失败: {e}", []
        # 捕获所有其他异常,写日志,返回简要错误说明
        except Exception as e: 
            logger.exception("streamable-http 检测失败")
            return False, f"streamable-http MCP 检测失败: {e}", []

    # 在主线程执行异步检测函数并返回结果
    return asyncio.run(_run_http_check())

# 定义测试sse协议MCP服务的函数
+def test_sse(config):
    # 从配置中获取url
+   url = config.get("url")
    # 如果url不存在或不是字符串,返回错误信息
+   if not url or not isinstance(url, str):
+       return False, "sse 配置需要字符串 url", []
    # 合并请求头
+   headers = merge_headers(config)

    # 定义异步检测函数
+   async def _run_sse_check():
+       try:
            # 建立与sse服务的异步连接,获取读写对象
+           async with sse_client(
+               url, headers=headers, httpx_client_factory=mcp_httpx_client_factory
+           ) as (read, write):
                # 调用探测工具初始化session并获取工具列表
+               return await probe_with_session(read, write, "sse")
        # 捕获超时异常,返回特定提示
+       except asyncio.TimeoutError:
+           return False, "等待响应超时;请检查 sse 服务地址与请求头", []
        # 捕获httpx请求异常,如网络不可达等
+       except httpx.RequestError as e:
+           return False, f"请求失败: {e}", []
        # 捕获其它异常,写日志,返回简要错误说明
+       except Exception as e:  # noqa: BLE001
+           logger.exception("sse 检测失败")
+           return False, f"sse MCP 检测失败: {e}", []

    # 在主线程运行异步检测函数并返回结果
+   return asyncio.run(_run_sse_check())

# MCP通用检测入口,根据协议选择检测方法
def test_mcp(protocol, config):
    # 将协议字符串归一化为小写去除空格
    p = (protocol or "").lower().strip()
    # 如果协议是streamable-http或http则用http检测方法
    if p in {"streamable-http", "http"}:
        return test_streamable_http(config)
+   if p == "sse":
+       return test_sse(config)    
    # 否则返回不支持的协议
    return False, f"不支持的协议: {protocol}", []

12.4 测试 #

curl --location --request POST "http://127.0.0.1:8000/api/mcp-services/test" ^
--header "Content-Type: application/json" ^
--data-raw "{  \"protocol\": \"sse\",  \"config\": {    \"url\": \"http://127.0.0.1:8001/sse\",    \"headers\": {      \"BAIDU_MAP_AK\": \"51wJobm0zfEyyZv1FGSAGmvrBAXGrqU9\",      \"DEEPSEEK_API_KEY\": \"sk-24088156e9ab48f3adddaf5a9c0c4ede\"    }  }}"

12.5 地点检索服务 #

12.5.1 描述 #

面向中文出行场景的地点检索服务。用户输入自然语言需求(如“北京景点”“上海酒店”)后,服务会先用 DeepSeek 抽取检索参数(地区、关键词、标签、是否限制本地),再调用百度 Place API 返回地点列表;可按需补充详情信息(电话、人均、营业时间),并在无结果时给出联想词建议。适用于 AI 助手的景点/酒店查询与行程决策场景。

12.5.2 协议 #

sse

12.5.3 URL #

http://127.0.0.1:8001/sse

12.5.4 请求头 #

{
  "BAIDU_MAP_AK": "51wJobm0zfEyyZv1FGSAGmvrBAXGrqU9",
  "DEEPSEEK_API_KEY": "sk-24088156e9ab48f3adddaf5a9c0c4ede"
}

12.6 时序图 #

sequenceDiagram autonumber participant Caller as 调用方 participant TM as test_mcp participant TS as test_sse participant MH as merge_headers participant AR as asyncio participant RSC as run_sse_check participant SSE as sse_client participant HF as httpx_factory participant Remote as 远端MCP_SSE participant PWS as probe_with_session participant CS as ClientSession Caller->>TM: 传入 protocol 与 config TM->>TS: test_sse config Note over TS: 同步阶段 读取并校验 url alt url 无效或不是字符串 TS-->>Caller: 失败 需要字符串 url 空工具列表 else url 有效 TS->>MH: 合并 config 中的请求头为字符串键值 MH-->>TS: headers 字典 Note over TS,AR: 异步阶段 由 asyncio.run 驱动事件循环 TS->>AR: asyncio.run run_sse_check AR->>RSC: 执行内部协程 RSC->>SSE: 建立 SSE 传输 url 与 headers RSC->>HF: 通过工厂创建 httpx 客户端 Note over HF: trust_env 为 false 避免本机代理干扰 localhost HF-->>SSE: AsyncClient SSE->>Remote: 长连接 SSE 与 MCP 消息 Remote-->>SSE: 可读可写传输端点 SSE-->>RSC: read write RSC->>PWS: 用 read write 探测 MCP 服务能力 PWS->>CS: ClientSession 绑定传输 CS->>Remote: initialize 初始化 单次超时 15 秒 Remote-->>CS: 服务端信息与能力 loop 最多尝试三次 list_tools CS->>Remote: 列出工具 Remote-->>CS: 工具结果 opt 工具列表仍为空且还可重试 PWS->>AR: 等待 0.2 秒再试 end end PWS->>PWS: 提取并规范化工具 name 描述 schema PWS-->>RSC: 成功 提示文案 工具列表 RSC-->>SSE: 关闭 SSE 与客户端上下文 SSE-->>AR: 三元组结果 AR-->>TS: 三元组结果 TS-->>Caller: 成功或失败 消息 工具数组 end

13. 天气查询服务 #

  • 百度国内天气查询SDK

服务综述

天气查询服务(weather MCP 服务)基于 MCP 通信协议,设计用于获取中国大陆地区未来天气预报。与传统 API 接口不同,本服务采用 stdio 协议作为输入输出通道,适用于被各类 AI 助手客户端(如 Cursor、Claude Desktop 等支持 MCP 的程序)直接集成。
本服务支持通过自然语言输入目的地及天数。它自动通过 DeepSeek 大模型抽取并规整请求参数,然后调用百度天气开放接口获取对应的天气数据,最终以易读中文回复。

主要功能亮点:

  • 支持自然语言输入:用户只需输入「去成都3天天气」等描述,无需提前查找地区编码。
  • 精准参数抽取:结合 DeepSeek 大模型能力解析自然语言,自动匹配百度天气 API 所需参数(如 district_id)。
  • 国内天气覆盖:基于百度开放平台,准确查询中国境内绝大多数省市区县的7日预报。
  • 标准 MCP 工具协议:适配 AI 智能体工具链,并支持多种客户端直连。

协议与调用方法

本服务基于 MCP stdio 协议实现,需要由外部进程(如 AI 客户端或命令行用户)以标准输入/输出的方式启动和交互。启动服务的推荐命令如下:

uv run --directory D:\aprepare\mcp_agent\mcp-services weather-server.py

其中:

  • uv 是快速启动 Python 脚本的 runner,具备「热重载」和更低延迟的优点。
  • run 与 --directory 及脚本路径需要根据实际部署目录调整。
  • weather-server.py 为本服务主程序文件。

环境变量请务必正确设置,包括:

  • BAIDU_MAP_AK:百度地图开放平台的 API Key(用于天气及地理相关接口调用)
  • DEEPSEEK_MODEL:DeepSeek 所用模型(如 "deepseek-chat",如无可省略)
  • DEEPSEEK_API_KEY:DeepSeek 大模型的 API Key
  • DEEPSEEK_BASE_URL:DeepSeek 后端地址

可参照如下 shell 伪代码:

export BAIDU_MAP_AK=你的百度API_KEY
export DEEPSEEK_API_KEY=你的DeepSeek_KEY
# ...其余变量...
uv run --directory 项目路径 weather-server.py

配置参数说明

字段名 类型 说明 示例
command str 可执行命令,推荐 uv "uv"
args list 启动参数,依次填写子命令及路径等 ["run", "--directory", "D:/aprepare/mcp_agent/mcp-services", "weather-server.py"]
env object 环境变量字典,参见上文 {...}

请求体示例:

{
  "protocol": "stdio",
  "config": {
    "command": "uv",
    "args": [
      "run",
      "--directory",
      "D:/aprepare/mcp_agent/mcp-services",
      "weather-server.py"
    ],
    "env": {
      "BAIDU_MAP_AK": "51wJobm0zfEyyZv1FGSAGmvrBAXGrqU9",
      "DEEPSEEK_MODEL": "deepseek-chat",
      "DEEPSEEK_API_KEY": "sk-24088156e9ab48f3adddaf5a9c0c4ede",
      "DEEPSEEK_BASE_URL": "https://api.deepseek.com"
    }
  }
}

检测与测试方法

可用 HTTP POST 方式检测服务可用性,只需调用 /api/mcp-services/test,请求体举例:

curl --location --request POST "http://127.0.0.1:8000/api/mcp-services/test" ^
--header "Content-Type: application/json" ^
--data-raw "{  \"protocol\": \"stdio\",  \"config\": {    \"env\": {      \"BAIDU_MAP_AK\": \"51wJobm0zfEyyZv1FGSAGmvrBAXGrqU9\",      \"DEEPSEEK_MODEL\": \"deepseek-chat\",      \"DEEPSEEK_API_KEY\": \"sk-24088156e9ab48f3adddaf5a9c0c4ede\",      \"DEEPSEEK_BASE_URL\": \"https://api.deepseek.com\"    },    \"args\": [      \"run\",      \"--directory\",      \"D:/aprepare/mcp-backend/mcp-services\",      \"weather-server.py\"    ],    \"command\": \"uv\"  }}"

场景举例与用途

  • 用户:直接询问「我去杭州玩4天,这几天天气如何?」
  • AI 助手:自动调用本服务,返回易于直接引用的天气描述、未来多日温度与降水预报,并结合天气辅助旅行建议。

与客户端集成建议

  • 推荐使用 mcp.client.stdio 标准库或你所用智能体的 MCP Stdio 客户端模块。
  • 若需 CLI 级调试可参考 weather-client.py 示例代码。
  • 建议在生产环境下合理配置环境变量,确保 DeepSeek 及百度相关密钥安全。

13.1. weather-client.py #

mcp-services/weather-client.py

"""调试用:启动 weather stdio MCP 并调用 get_travel_forecast。"""

# 导入异步IO模块
import asyncio
# 导入系统模块(用于获取解释器路径)
import sys
# 导入Path对象,用于操作文件路径
from pathlib import Path

# 从mcp库导入ClientSession和StdioServerParameters工具
from mcp import ClientSession, StdioServerParameters
# 从mcp.client.stdio导入stdio_client,用于与stdio协议MCP服务通讯
from mcp.client.stdio import stdio_client

# 获取当前文件同目录下的 weather-server.py 路径
SERVER_FILE = Path(__file__).with_name("weather-server.py")
# 设定要查询的目的地
DESTINATION = "北京"
# 设定要查询的天数
DAYS = 3
# 设置百度地图AK
BAIDU_MAP_AK = "51wJobm0zfEyyZv1FGSAGmvrBAXGrqU9"
# 设置DeepSeek API Key
DEEPSEEK_API_KEY = "sk-24088156e9ab48f3adddaf5a9c0c4ede"
# 设置DeepSeek的基础URL
DEEPSEEK_BASE_URL = "https://api.deepseek.com"
# 设置DeepSeek所用的模型名
DEEPSEEK_MODEL = "deepseek-chat"


# 定义异步主流程
async def run():
    # 初始化StdioServerParameters,配置待启动的天气服务所需命令和环境变量
    server = StdioServerParameters(
        command=sys.executable,
        args=[str(SERVER_FILE)],
        env={
            "BAIDU_MAP_AK": BAIDU_MAP_AK,
            "DEEPSEEK_API_KEY": DEEPSEEK_API_KEY,
            "DEEPSEEK_BASE_URL": DEEPSEEK_BASE_URL,
            "DEEPSEEK_MODEL": DEEPSEEK_MODEL,
        },
    )
    # 启动并连接weather-server进程,获取读写流
    async with stdio_client(server) as (read, write):
        # 创建MCP会话
        async with ClientSession(read, write) as session:
            # 初始化会话
            await session.initialize()
            # 调用天气查询工具
            result = await session.call_tool(
                "get_travel_forecast",
                {"destination": DESTINATION, "days": DAYS},
            )
            # 检查是否有错误,如有,抛出异常
            if result.isError:
                raise RuntimeError(str(result.content))
            # 遍历返回的内容并输出其中的“text”字段
            for item in result.content:
                text = getattr(item, "text", "")
                if text:
                    print(text)


# 定义主入口,运行异步主流程
def main():
    asyncio.run(run())


# 判断是否直接运行此文件,若是则执行main
if __name__ == "__main__":
    main()

13.2. weather-server.py #

mcp-services/weather-server.py

# 天气 MCP 服务器(stdio):目的地 + N 天 -> DeepSeek 参数抽取 -> 百度天气预报。
"""
天气 MCP 服务器(stdio):目的地 + N 天 -> DeepSeek 参数抽取 -> 百度天气预报。
"""

# 导入标准库模块
import json
import logging
import os
from typing import Annotated

# 导入第三方库
import httpx
from langchain_core.output_parsers import JsonOutputParser
from langchain_core.prompts import ChatPromptTemplate
from langchain_deepseek import ChatDeepSeek
from mcp.server.fastmcp import FastMCP
from pydantic import Field

# 配置日志格式与日志级别
logging.basicConfig(level=logging.INFO, format="%(asctime)s %(levelname)s %(name)s %(message)s")
# 创建名为 weather-service 的日志记录器
logger = logging.getLogger("weather-service")
# 创建 FastMCP 实例,服务名为“天气查询服务”
mcp = FastMCP("天气查询服务")
# 百度天气 API 的 URL
WEATHER_V1_URL = "https://api.map.baidu.com/weather/v1/"


# 获取百度地图 AK,若未设置则抛出异常
def _ak():
    v = (os.environ.get("BAIDU_MAP_AK") or "").strip()
    if not v:
        raise RuntimeError("缺少环境变量 BAIDU_MAP_AK")
    return v


# 获取 DeepSeek 对象,检查 API KEY 环境变量是否存在
def _deepseek():
    key = (os.environ.get("DEEPSEEK_API_KEY") or "").strip()
    if not key:
        raise RuntimeError("缺少环境变量 DEEPSEEK_API_KEY")
    # 返回 DeepSeek 对象,支持自定义 base_url 和模型,默认为 deepseek-chat
    return ChatDeepSeek(
        api_key=key,
        base_url=(os.environ.get("DEEPSEEK_BASE_URL") or "https://api.deepseek.com").strip(),
        model=(os.environ.get("DEEPSEEK_MODEL") or "deepseek-chat").strip(),
        temperature=0,
    )


# 异步方法:调用大模型抽取天气查询参数
async def _extract_params(destination):
    # 日志记录参数抽取开始
    logger.info("开始参数抽取,destination=%s", destination)
    # 创建 JSON 输出解析器
    parser = JsonOutputParser()
    # 构建用于参数抽取的 PromptTemplate
    prompt = ChatPromptTemplate.from_template(
        """
        从输入中抽取百度天气接口参数,只输出 JSON:
        {{
        "district_id": "",
        "province": "",
        "city": "",
        "district": ""
        }}
        输入:{destination}
        输出格式要求:{format_instructions}
        """.strip()
    )
    # 串联 prompt、deepseek 和 JSON parser
    chain = prompt | _deepseek() | parser
    # 调用链式执行,抽取参数
    data = await chain.ainvoke(
        {
            "destination": destination,
            "format_instructions": parser.get_format_instructions(),
        }
    )
    # 整理抽取结果,保证字段存在且类型为字符串
    extracted = {
        "district_id": str(data.get("district_id") or "").strip(),
        "province": str(data.get("province") or "").strip(),
        "city": str(data.get("city") or "").strip(),
        "district": str(data.get("district") or "").strip(),
    }
    # 记录抽取结果日志
    logger.info("参数抽取完成:%s", json.dumps(extracted, ensure_ascii=False))
    # 返回抽取的参数
    return extracted


# 异步方法:调用百度天气接口获取天气预报
async def _fetch_forecast(params_from_llm):
    # 构建基础查询参数,data_type=fc 表示未来天气预报
    params = {"data_type": "fc", "output": "json", "ak": _ak()}
    # 如果抽取结果存在 district_id,优先使用
    if params_from_llm["district_id"]:
        params["district_id"] = params_from_llm["district_id"]
    # 否则按 district -> province/city 顺序补全
    elif params_from_llm["district"]:
        params["district"] = params_from_llm["district"]
        if params_from_llm["province"]:
            params["province"] = params_from_llm["province"]
        if params_from_llm["city"]:
            params["city"] = params_from_llm["city"]
    # 如果有 city,用 city 作为 district
    elif params_from_llm["city"]:
        params["district"] = params_from_llm["city"]
        if params_from_llm["province"]:
            params["province"] = params_from_llm["province"]
    # 如果只剩 province,用 province 作为 district
    elif params_from_llm["province"]:
        params["district"] = params_from_llm["province"]
    # 若以上都没有,抛出异常
    else:
        raise RuntimeError("LLM 未提取出有效地点(district_id/district/city/province)")

    # 日志记录即将调用百度天气接口
    logger.info("调用百度天气接口,params=%s", json.dumps(params, ensure_ascii=False))
    # 创建 httpx 异步客户端,设置超时时间为 30 秒
    async with httpx.AsyncClient(timeout=30.0) as client:
        # 向百度天气接口发起 GET 请求
        r = await client.get(WEATHER_V1_URL, params=params)
        # 请求异常时抛出异常
        r.raise_for_status()
        # 解析返回的 json 数据
        data = r.json()
    # 检查百度天气 API 返回状态
    if data.get("status") != 0:
        raise RuntimeError(f"百度天气接口失败: status={data.get('status')} message={data.get('message')}")
    # 日志记录接口调用成功
    logger.info("百度天气接口调用成功")
    # 返回“result”字段内容
    return data.get("result") or {}


# 注册 MCP 工具
@mcp.tool()
# 异步方法:获取指定目的地与天数的天气预报
async def get_travel_forecast(
    destination: Annotated[
        str,
        Field(description="旅游目的地,例如:吉林省延边朝鲜族自治州龙井市"),
    ],
    days: Annotated[
        int,
        Field(description="展示未来天气天数,范围 1~15,默认 3"),
    ] = 3,
):
    # 限定展示天数在 1~15 范围
    days = max(1, min(int(days), 15))
    # 调用参数抽取逻辑
    params_from_llm = await _extract_params(destination)
    # 调用接口查询天气预报结果
    result = await _fetch_forecast(params_from_llm)
    # 获取 forecasts 列表(未来各天的天气预报)
    forecasts = list(result.get("forecasts") or [])
    # 日志记录生成天气预报结果
    logger.info("生成天气预报结果,destination=%s days=%s", destination, days)
    # 生成结果的文本行列表
    lines = [
        f"输入目的地:{destination}",
        f"参数:{json.dumps(params_from_llm, ensure_ascii=False)}",
        f"展示天数:{min(days, len(forecasts))}",
        "",
    ]
    # 遍历前 days 条天气信息,依次生成每一天的信息
    for day in forecasts[:days]:
        lines.append(f"{day.get('date', '')} {day.get('week', '')}")
        lines.append(f"白天:{day.get('text_day', '')},夜间:{day.get('text_night', '')}")
        lines.append(f"温度:{day.get('low', '')}~{day.get('high', '')}℃")
        lines.append("")
    # 返回多行文本,用于 AI 助手等调用
    return "\n".join(lines)


# 程序主入口,启动 MCP 服务器(stdio 模式)
def main():
    mcp.run(transport="stdio")


# 判断是否直接运行此文件,若是则执行 main()
if __name__ == "__main__":
    main()

13.3. mcp_tester.py #

app/services/mcp_tester.py


# 导入异步库asyncio
import asyncio
# 导入自定义的streamable_http_client客户端
from mcp.client.streamable_http import streamable_http_client
# 导入日志库
import logging
# 导入httpx用于网络请求
import httpx
# 导入os模块用于操作系统
+import os
# 导入shutil模块用于文件操作
+import shutil
# 导入Any类型用于类型注解
+from typing import Any
# 导入stdio_client客户端
+from mcp.client.stdio import stdio_client
# 从mcp模块导入ClientSession用于会话管理
+from mcp import ClientSession,StdioServerParameters
# 导入自定义的mcp_httpx_client_factory工厂函数
from app.services.mcp_httpx import mcp_httpx_client_factory
# 导入sse_client客户端
from mcp.client.sse import sse_client
# 获取logger对象用于日志输出
logger = logging.getLogger(__name__)

# 合并headers辅助函数,将配置中的header全部转换为字符串格式
def merge_headers(config):
    # 获取headers配置
    raw = config.get("headers")
    # 如果headers为空或不是字典类型,返回空字典
    if not raw or not isinstance(raw, dict):
        return {}
    # 创建输出字典
    out = {}
    # 将所有键值都转成字符串
    for k, v in raw.items():
        out[str(k)] = str(v)
    # 返回处理后的headers
    return out

# 提取工具列表
def extract_tools(tools_result):
    # 如果结果为None,返回空列表
    if tools_result is None:
        return []
    # 如果本身就是列表,直接返回
    if isinstance(tools_result, list):
        return tools_result
    # 否则尝试从tools_result对象中获取tools属性
    tools = getattr(tools_result, "tools", None)
    # 如果tools属性为列表,返回之
    if isinstance(tools, list):
        return tools
    # 否则返回空列表
    return []

# 标准化tool对象,确保返回字典结构{name, description, input_schema}
def normalize_tool(tool):
    # 如果tool为None,返回默认空结构
    if tool is None:
        return {"name": "", "description": "", "input_schema": {}}
    # 打印调试信息
    print("name", getattr(tool, "name", ""))
    print("description", getattr(tool, "description", ""))
    print("input_schema", getattr(tool, "inputSchema", {}))
    # 返回规范化后的工具描述
    return {
        "name": getattr(tool, "name", ""),
        "description": getattr(tool, "description", ""),
        "input_schema": getattr(tool, "inputSchema", {}),
    }

# 通过给定的read/write对象与MCP服务初始化,并探测tools列表
async def probe_with_session(read, write, protocol):
    # 使用ClientSession进行会话管理
    async with ClientSession(read, write) as session:
        # 初始化会话
        initialize_result = await session.initialize()
        # 获取工具列表
        tools_result = await session.list_tools()
        # 提取工具对象
        raw_tools = extract_tools(tools_result)
        # 归一化所有工具对象
        tools = [normalize_tool(tool) for tool in raw_tools]
        # 日志输出工具数量
        logger.info("%s 探测到了工具列表的数量为=%s", protocol, len(tools))
        # 获取服务信息
        server_info = getattr(initialize_result, "serverInfo", None)
        # 如果服务信息含有名称字段
        if server_info and getattr(server_info, "name", None):
            return True, f"{protocol}MCP服务初始化成功:{server_info.name}", tools
        # 否则返回通用成功消息
        return True, f"{protocol}MCP服务初始化成功", tools


# 测试 streamable-http 协议的 MCP 服务
def test_streamable_http(config):
    # 从配置中获取 url
    url = config.get("url")
    # 若 url 无效或不是字符串,直接返回错误信息
    if not url or not isinstance(url, str):
        return False, "streamable-http 配置需要字符串 url", []
    # 标准化合并 headers
    headers = merge_headers(config)

    # 定义异步检测函数
    async def _run_http_check():
        try:
            # 使用定制的 httpx async client 工厂函数创建客户端(避免本地代理干扰)
            async with mcp_httpx_client_factory(headers=headers) as http_client:
                # 以异步方式建立与 streamable-http MCP 服务的连接,并获取读写对象
                async with streamable_http_client(url, http_client=http_client) as (read, write, _):
                    # 调用内部探测逻辑初始化 session 并获取工具列表
                    return await probe_with_session(read, write, "streamable-http")
        # 捕获超时异常(如连接或响应过慢),返回特定的提示
        except asyncio.TimeoutError:
            return False, "等待响应超时;请检查 streamable-http 服务地址与请求头", []
        # 捕获 httpx 的请求异常,如网络不可达等
        except httpx.RequestError as e:
            return False, f"请求失败: {e}", []
        # 捕获所有其他异常,写日志,返回简要错误说明
        except Exception as e: 
            logger.exception("streamable-http 检测失败")
            return False, f"streamable-http MCP 检测失败: {e}", []

    # 在主线程执行异步检测函数并返回结果
    return asyncio.run(_run_http_check())

# 定义测试sse协议MCP服务的函数
def test_sse(config):
    # 从配置中获取url
    url = config.get("url")
    # 如果url不存在或不是字符串,返回错误信息
    if not url or not isinstance(url, str):
        return False, "sse 配置需要字符串 url", []
    # 合并请求头
    headers = merge_headers(config)

    # 定义异步检测函数
    async def _run_sse_check():
        try:
            # 建立与sse服务的异步连接,获取读写对象
            async with sse_client(
                url, headers=headers, httpx_client_factory=mcp_httpx_client_factory
            ) as (read, write):
                # 调用探测工具初始化session并获取工具列表
                return await probe_with_session(read, write, "sse")
        # 捕获超时异常,返回特定提示
        except asyncio.TimeoutError:
            return False, "等待响应超时;请检查 sse 服务地址与请求头", []
        # 捕获httpx请求异常,如网络不可达等
        except httpx.RequestError as e:
            return False, f"请求失败: {e}", []
        # 捕获其它异常,写日志,返回简要错误说明
        except Exception as e:  # noqa: BLE001
            logger.exception("sse 检测失败")
            return False, f"sse MCP 检测失败: {e}", []

    # 在主线程运行异步检测函数并返回结果
    return asyncio.run(_run_sse_check())


# 定义用于测试 stdio 协议 MCP 服务的函数
+def test_stdio(config):
    # 从配置中获取 command 字段
+   command = config.get("command")
    # 检查 command 是否存在且为字符串
+   if not command or not isinstance(command, str):
+       return False, "stdio 配置需要字符串字段 command", []
    # 获取 args 参数,为空时默认为空列表
+   args = config.get("args") or []
    # 检查 args 是否为列表类型
+   if not isinstance(args, list):
+       return False, "stdio 配置的 args 须为数组", []
    # 获取 env(环境变量)字段
+   env_vars = config.get("env")
    # 复制当前环境变量
+   merged_env = os.environ.copy()
    # 如果配置提供了额外环境变量且为字典,将其合并进当前环境变量
+   if env_vars and isinstance(env_vars, dict):
+       for k, v in env_vars.items():
+           merged_env[str(k)] = str(v)

    # 取出 command 作为可执行文件
+   exe = command
    # 如果 exe 不是绝对路径且不含路径分隔符,则在 PATH 里查找实际路径
+   if exe and not os.path.isabs(exe) and os.sep not in exe:
+       found = shutil.which(exe)
        # 如果找不到可执行文件则返回错误
+       if not found:
+           return False, f"找不到可执行文件: {command}", []
        # 找到则更新 exe 为实际路径
+       exe = found

    # 定义内部异步检测函数
+   async def _run_stdio_check():
+       try:
            # 生成 StdioServerParameters 实例,包装启动参数
+           server = StdioServerParameters(
+               command=exe,
+               args=[str(a) for a in args],
+               env=merged_env,
+           )
            # 以异步方式创建 stdio 客户端并建立连接
+           async with stdio_client(server) as (read, write):
                # 调用探测函数检查 stdio MCP 服务
+               return await probe_with_session(read, write, "stdio")
        # 捕获超时异常,返回特定的错误提示
+       except asyncio.TimeoutError:
+           return False, "等待响应超时;请检查命令与参数是否为 MCP stdio 服务", []
        # 捕获找不到文件异常,返回具体的错误信息
+       except FileNotFoundError:
+           return False, f"找不到可执行文件: {exe}", []
        # 捕获无法启动进程的异常
+       except OSError as e:
+           return False, f"无法启动进程: {e}", []
        # 捕获所有其他异常,写入日志并返回通用错误说明
+       except Exception as e:  # noqa: BLE001
+           logger.exception("stdio 检测失败")
+           return False, f"stdio MCP 检测失败: {e}", []

    # 在主线程运行异步检测,并返回检测结果
+   return asyncio.run(_run_stdio_check())


# MCP通用检测入口,根据协议选择检测方法
def test_mcp(protocol, config):
    # 将协议字符串归一化为小写去除空格
    p = (protocol or "").lower().strip()
    # 如果协议是streamable-http或http则用http检测方法
    if p in {"streamable-http", "http"}:
        return test_streamable_http(config)
    if p == "sse":
        return test_sse(config)    
+   if p == "stdio":
+       return test_stdio(config)    
    # 否则返回不支持的协议
    return False, f"不支持的协议: {protocol}", []

13.4 测试 #

curl --location --request POST "http://127.0.0.1:8000/api/mcp-services/test" ^
--header "Content-Type: application/json" ^
--data-raw "{  \"protocol\": \"stdio\",  \"config\": {    \"env\": {      \"BAIDU_MAP_AK\": \"51wJobm0zfEyyZv1FGSAGmvrBAXGrqU9\",      \"DEEPSEEK_MODEL\": \"deepseek-chat\",      \"DEEPSEEK_API_KEY\": \"sk-24088156e9ab48f3adddaf5a9c0c4ede\",      \"DEEPSEEK_BASE_URL\": \"https://api.deepseek.com\"    },    \"args\": [      \"run\",      \"--directory\",      \"D:/aprepare/mcp-backend/mcp-services\",      \"weather-server.py\"    ],    \"command\": \"uv\"  }}"

13.5 天气查询服务 #

13.5.1 描述 #

一个面向中文出行场景的天气查询 MCP 服务。用户只需输入旅游目的地和天数,服务会先通过 DeepSeek 从自然语言中提取百度天气接口所需的区域参数(如 district_id、省市区),再调用百度天气预报 API 获取未来天气,并以易读文本返回给 AI 助手用于行程建议和天气提醒。服务采用 stdio 传输协议,适合被 Cursor、Claude Desktop 等支持 MCP 的客户端直接接入。

13.5.2 协议 #

stdio

13.5.3 命令 #

uv

13.5.4 参数 #

run
--directory
D:\aprepare\mcp_agent\mcp-services
weather-server.py

13.5.5 环境变量 #

BAIDU_MAP_AK=51wJobm0zfEyyZv1FGSAGmvrBAXGrqU9
DEEPSEEK_MODEL=deepseek-chat
DEEPSEEK_API_KEY=sk-24088156e9ab48f3adddaf5a9c0c4ede
DEEPSEEK_BASE_URL=https://api.deepseek.com

13.6 时序图 #

sequenceDiagram autonumber participant Caller as 调用方 participant TM as test_mcp participant TS as test_stdio participant AR as asyncio participant RSC as _run_stdio_check participant SSP as StdioServerParameters participant SC as stdio_client participant Sub as 子进程_MCP participant PWS as probe_with_session participant CS as ClientSession participant Remote as MCP_子进程侧协议 Caller->>TM: test_mcp stdio 分支 TM->>TS: test_stdio config Note over TS: 同步阶段 校验并准备参数 TS->>TS: 读取 command 须为非空字符串 alt command 或 args 不合法 TS-->>Caller: 失败 配置错误 空工具列表 else 合法 TS->>TS: 复制 os.environ 合并 config.env TS->>TS: 若非绝对路径且无路径分隔符 则用 which 解析 exe opt PATH 中找不到命令 TS-->>Caller: 失败 找不到可执行文件 end Note over TS,AR: 进入异步 统一由 asyncio.run 驱动 TS->>AR: asyncio.run _run_stdio_check AR->>RSC: 执行协程 RSC->>SSP: 构造启动参数 command args env RSC->>SC: async with stdio_client server SC->>Sub: 启动子进程 标准输入输出作为传输 Sub-->>SC: 得到 read write 管道 RSC->>PWS: 探测会话 read write 标签 stdio PWS->>CS: 建立 ClientSession CS->>Remote: initialize 初始化 超时约 15 秒 Remote-->>CS: 初始化结果 loop 最多三次 CS->>Remote: list_tools 列出工具 Remote-->>CS: 工具列表 opt 列表为空且还可重试 PWS->>AR: sleep 0.2 秒 end end PWS->>PWS: 规范化工具元数据 PWS-->>RSC: 成功 消息 工具列表 RSC-->>SC: 退出上下文 回收子进程与管道 SC-->>AR: 返回三元组 AR-->>TS: 返回三元组 TS-->>Caller: 成功或失败 消息 工具列表 end

14. 添加大模型 #

本章节将介绍如何在系统中添加和管理大模型(如 DeepSeek、ChatGLM 等)。
支持存储和管理以下信息:模型提供商、图标、API 基础地址、API 密钥、密钥申请地址、模型名称列表等,支持通过管理后台或 API 快速添加第三方大模型,便于灵活扩展后续的 LLM 底座能力。

主要能力说明

  • 模型信息注册:可在系统中注册不同厂商的大模型,包括 API 地址、密钥、模型名称等关键信息。
  • 多模型管理:支持一套系统中接入多个大模型,便于按需扩展和切换。
  • 字段校验与标准化:注册数据通过 Pydantic 严格校验及去重、去空格处理,确保数据合规和规范。
  • 接口调用安全:敏感信息(如密钥)支持表单加密传输,系统仅在实际调用时访问,并可配置密钥申请帮助地址。
  • 使用场景示例:通过 API、管理后台页面等方式,管理员可动态添加/维护大模型配置,从而灵活适配新模型。

适用场景举例

  • 统一运维各类 LLM 能力(DeepSeek、Azure OpenAI、聊聊GLM等),按需切换模型自动适配。
  • 企业/个人研发者根据自身授权情况,导入属于自己的 API Key 并指定模型列表,系统内用户即可调用。
  • 快速接入新兴大模型(如 DeepSeek Reasoner),只需在管理后台新增条目,无需变更代码。

14.1. llm_repository.py #

app/repositories/llm_repository.py

# 导入SQLAlchemy的Session类,用于数据库会话
from sqlalchemy.orm import Session

# 从app包导入models模块,包含数据库模型
from app import models
# 从app包导入schemas模块,包含数据校验模型
from app import schemas

# 定义创建LLM模型记录的函数,接收数据库会话和入参数据,返回新建的LlmModel实例
def create_llm_model(session: Session, data: schemas.LlmModelCreate) -> models.LlmModel:
    # 创建LlmModel数据库对象,剔除多余空白并赋值各字段
    row = models.LlmModel(
        provider_name=data.provider_name.strip(),  # 提供商名称,去除首尾空白
        provider_icon=data.provider_icon,          # 提供商图标
        api_base_url=data.api_base_url.strip(),    # API基础地址,去除首尾空白
        api_key=data.api_key.strip(),              # API密钥,去除首尾空白
        api_key_url=data.api_key_url.strip() if data.api_key_url else None,  # API密钥申请地址,有则去除空白,无则为None
        model_names=data.model_names,              # LLM模型名称列表
    )
    # 添加新纪录到数据库session
    session.add(row)
    # 提交事务保存数据
    session.commit()
    # 刷新session获取新数据
    session.refresh(row)
    # 返回新建的LlmModel对象
    return row

14.2. llm_models.py #

app/routers/llm_models.py

# 导入urljoin用于拼接URL
from urllib.parse import urljoin

# 导入httpx库(可用于发送HTTP请求,本文件未用到)
import httpx
# 从fastapi库导入APIRouter用于创建路由,Depends用于依赖注入,HTTPException用于异常处理
from fastapi import APIRouter, Depends, HTTPException
# 从sqlalchemy导入IntegrityError,用于捕获唯一性冲突异常
from sqlalchemy.exc import IntegrityError
# 导入Session会话,用于数据库操作
from sqlalchemy.orm import Session

# 导入schemas定义的Pydantic模型
from app import schemas
# 导入llm_repository,用于大模型数据操作
from app.repositories import llm_repository
# 导入get_session获取数据库依赖
from app.database import get_session

# 定义extract_models函数,用于从响应体中提取模型名称列表
def extract_models(body):
    # 从body字典中获取"data"字段
    data = body.get("data")
    # 初始化输出列表,保存模型名
    out: list[str] = []
    # 初始化集合,用于模型名去重(忽略大小写)
    seen: set[str] = set()
    # 遍历data数组中的每个条目
    for item in data:
        # 获取模型的"id"字段,强制转为字符串并去除首尾空白
        name = str(item.get("id") or "").strip()
        # 如果模型名为空则跳过
        if not name:
            continue
        # 将模型名转为小写,用于去重比对
        key = name.lower()
        # 如果已经在seen集合中,说明重复,跳过
        if key in seen:
            continue
        # 否则,将该模型名(小写)加入去重集合
        seen.add(key)
        # 将原始模型名加入输出列表
        out.append(name)
    # 返回去重后的模型名列表
    return out

# 创建APIRouter实例,设置路由前缀与标签
router = APIRouter(prefix="/api/llm-models", tags=["llm-models"])

# 定义POST类型接口,路径为"",响应模型为LlmModelOut
@router.post("", response_model=schemas.LlmModelOut)
def create_model(payload: schemas.LlmModelCreate, session: Session = Depends(get_session)):
    # 尝试创建大模型记录
    try:
        return llm_repository.create_llm_model(session, payload)
    # 捕获唯一性冲突异常(例如Provider的名字已存在)
    except IntegrityError:
        session.rollback()
        # 抛出HTTP 409异常,提示"提供商名称已存在"
        raise HTTPException(status_code=409, detail="提供商名称已存在")

# 定义路由POST接口 /probe,返回值为LlmModelTestResult
@router.post("/probe", response_model=schemas.LlmModelTestResult)
# 定义测试大模型服务的函数
def test_model_service(payload: schemas.LlmModelTestRequest):
    # 去除api_base_url首尾空白字符
    base_url = payload.api_base_url.strip()
    # 去除api_key首尾空白字符
    api_key = payload.api_key.strip()
    # 构造模型列表接口的URL
    models_url = urljoin(base_url.rstrip("/") + "/", "models")
    # 准备请求头,添加Authorization字段
    headers = {
        "Authorization": f"Bearer {api_key}",
    }
    try:
        # 创建HTTP客户端,设置超时时间和请求头
        with httpx.Client(timeout=20.0, headers=headers) as client:
            # 发送GET请求获取模型列表
            resp = client.get(models_url)
        # 检查HTTP响应状态码,若有异常则抛出
        resp.raise_for_status()
        # 解析响应体为JSON
        body = resp.json()
        # 调用工具函数提取模型名称列表
        names = extract_models(body if isinstance(body, dict) else {})
        # 如果检测到模型名称
        if names:
            # 返回检测通过且包含模型数量和名称的结果
            return schemas.LlmModelTestResult(
                ok=True,
                message=f"模型服务检测通过,可用模型 {len(names)} 个",
                models=names,
            )
        # 如果没检测到模型列表,返回通过但无模型名提示
        return schemas.LlmModelTestResult(
            ok=True,
            message="模型服务检测通过,但未识别到模型列表",
            models=[],
        )
    # 捕获请求超时异常,返回相应错误信息
    except httpx.TimeoutException:
        return schemas.LlmModelTestResult(ok=False, message="请求超时,请检查 API 地址")
    # 捕获HTTP状态异常,返回状态码和响应内容前300字符
    except httpx.HTTPStatusError as e:
        return schemas.LlmModelTestResult(ok=False, message=f"HTTP {e.response.status_code}: {e.response.text[:300]}")
    # 捕获所有其他异常,返回异常信息
    except Exception as e:  # noqa: BLE001
        return schemas.LlmModelTestResult(ok=False, message=f"模型服务检测失败: {e}")        

14.3. uploads.py #

app/routers/uploads.py

# 导入 uuid 库用于生成唯一文件名
import uuid
# 从 typing 导入 ClassVar,用于类型标注
from typing import ClassVar

# 从 fastapi 导入相关函数和类
from fastapi import APIRouter, File, HTTPException, UploadFile
# 从 pydantic 导入 BaseModel,用于定义响应模型
from pydantic import BaseModel
# 从app模块导入schemas用于数据校验和序列化
from app import schemas
# 导入自定义的配置 settings
from app.config import settings

# 创建带有前缀和 tags 的 FastAPI 路由对象
router = APIRouter(prefix="/api/uploads", tags=["uploads"])

# 定义最大上传文件大小为 5MB
_MAX_BYTES = 5 * 1024 * 1024
# 支持的图片 MIME 类型与文件扩展名映射
_CONTENT_TYPES: ClassVar[dict[str, str]] = {
    "image/jpeg": ".jpg",
    "image/png": ".png",
    "image/gif": ".gif",
    "image/webp": ".webp",
}

# 定义图片上传接口,响应模型为 UploadImageResult
@router.post("/image", response_model=schemas.UploadImageResult)
async def upload_image(file: UploadFile = File(...)) -> schemas.UploadImageResult:
    # 获取 Content-Type,分号前部分,转换小写,去除空白
    raw_ct = (file.content_type or "").split(";")[0].strip().lower()
    # 检查 Content-Type 是否为支持的图片类型
    if raw_ct not in _CONTENT_TYPES:
        # 若不支持则抛出 400 错误
        raise HTTPException(status_code=400, detail="只支持 JPEG、PNG、GIF、WebP 图片")
    # 根据 MIME 类型决定文件扩展名
    ext = _CONTENT_TYPES[raw_ct]
    # 读取上传的文件内容为二进制
    body = await file.read()
    # 检查文件大小是否超过最大限制
    if len(body) > _MAX_BYTES:
        # 超过限制抛出 400 错误
        raise HTTPException(status_code=400, detail="图片不能超过 5MB")
    # 检查文件内容是否为空
    if not body:
        # 空文件抛出 400 错误
        raise HTTPException(status_code=400, detail="空文件")
    # 生成唯一的文件名,附上文件扩展名
    name = f"{uuid.uuid4().hex}{ext}"
    # 计算目标文件保存路径
    dest = settings.upload_path() / name
    # 将图片内容写入目标路径
    dest.write_bytes(body)
    # 返回图片访问 URL
    return schemas.UploadImageResult(url=f"/uploads/{name}")

14.4. config.py #

app/config.py

# 导入Path对象,处理文件和目录路径
from pathlib import Path
# 导入BaseSettings和SettingsConfigDict,管理和配置设置项
from pydantic_settings import BaseSettings, SettingsConfigDict

# 获取当前文件的绝对路径
# 调用resolve()方法,将可能存在的符号链接转为真实路径
# 获取当前文件的父级目录(即本文件所在的目录)
# 再获取父级目录的父级目录(即后端根目录)
+BACKEND_ROOT = Path(__file__).resolve().parent.parent

# 定义Settings类,用于读取和管理配置,继承自BaseSettings
class Settings(BaseSettings):
    # 配置Pydantic模型行为
    model_config = SettingsConfigDict(
        # 指定配置文件路径,默认读取根目录下的.env文件
        env_file=".env",
        # 指定.env文件的编码格式,防止乱码
        env_file_encoding="utf-8",
        # 忽略未在类中定义的多余字段
        extra="ignore",  # 还可用allow允许额外字段, forbid禁止额外字段并抛错
    )
    # 数据库连接字符串,设置mysql本地连接
    database_url: str = (
        "mysql+pymysql://root:root@127.0.0.1:3306/ai_agent?charset=utf8mb4"
    )
    # 允许跨域访问的前端地址,多个用逗号分隔
    cors_origins: str = "http://127.0.0.1:5173,http://127.0.0.1:5174"

    # 定义上传文件夹目录,默认为"uploads"(相对路径)
+   upload_dir: str = "uploads"

    # 定义获取上传目录绝对路径的方法,并确保路径存在
+   def upload_path(self) -> Path:
        # 创建Path对象,表示上传目录(可能是相对或绝对路径)
+       p = Path(self.upload_dir)
        # 判断路径是否为绝对路径
        # 如果是绝对路径,直接调用resolve()获得绝对路径
        # 如果是相对路径,则与后端根目录拼接后再调用resolve()获得绝对路径
+       upload_path = p.resolve() if p.is_absolute() else (BACKEND_ROOT / p).resolve()
        # 返回上传目录的绝对路径
+       return upload_path

# 实例化Settings对象,全局使用
settings = Settings()

14.5. main.py #

app/main.py

# 导入FastAPI框架
from fastapi import FastAPI
# 导入日志模块
import logging
# 导入异步上下文管理器
from contextlib import asynccontextmanager
# 导入CORS中间件
from fastapi.middleware.cors import CORSMiddleware
# 导入应用配置
from app.config import settings
# 导入数据库模型基类和数据库引擎
from app.database import Base, engine
# 导入所有模型
from app.models import *
# 导入MCP服务相关路由
+from app.routers import mcp_services,llm_models,uploads
# 导入静态文件中间件
+from fastapi.staticfiles import StaticFiles

# 配置日志输出级别为INFO
logging.basicConfig(level=logging.INFO)


# 使用异步上下文管理器定义FastAPI生命周期事件
@asynccontextmanager
async def lifespan(app: FastAPI):
    # 创建所有数据库表
    Base.metadata.create_all(bind=engine)
    # 保持应用运行,等待关闭时执行清理工作
    yield


# 实例化FastAPI应用,指定标题、版本、生命周期管理器
app = FastAPI(title="智能体服务", version="0.1.0", lifespan=lifespan)

# 解析并清洗跨域允许的来源列表
origins = [o.strip() for o in settings.cors_origins if o.strip()]

# 添加跨域中间件,允许指定来源跨域访问
app.add_middleware(
    CORSMiddleware,
    allow_origins=origins,
    allow_credentials=True,
    allow_methods=["*"],
    allow_headers=["*"],
)

# 注册 MCP 服务相关路由
app.include_router(mcp_services.router)
# 注册大模型相关路由
+app.include_router(llm_models.router)
# 注册上传相关路由
+app.include_router(uploads.router)
# 获取上传文件夹的绝对路径
+upload_root = settings.upload_path()
# 确保上传文件夹存在,如不存在则创建
+upload_root.mkdir(parents=True, exist_ok=True)
# 挂载静态文件目录到 /uploads 路径
+app.mount("/uploads", StaticFiles(directory=str(upload_root)), name="uploads")
# 健康检查接口
@app.get("/health")
def health():
    # 返回服务状态ok
    return {"status": "ok"}

14.6. models.py #

app/models.py

# 导入用于处理日期和时间的datetime模块
from datetime import datetime
# 从SQLAlchemy中导入常用的数据类型和函数
from sqlalchemy import DateTime, String, Text, func
# 导入MySQL方言下的JSON字段类型
from sqlalchemy.dialects.mysql import JSON
# 导入ORM映射相关的类型声明和字段映射函数
from sqlalchemy.orm import Mapped, mapped_column

# 从项目数据库模块导入ORM基类
from app.database import Base


# 定义MCP服务的ORM模型
class McpService(Base):
    # 指定数据库表名为"mcp_services"
    __tablename__ = "mcp_services"
    # 定义主键id字段,自增
    id: Mapped[int] = mapped_column(primary_key=True, autoincrement=True)
    # 服务名称,最大长度255,唯一且有索引
    name: Mapped[str] = mapped_column(String(255), unique=True, index=True)
    # 服务描述,可空,使用Text类型
    description: Mapped[str | None] = mapped_column(Text, nullable=True)
    # 协议字段,最大长度32,非空
    protocol: Mapped[str] = mapped_column(String(32), nullable=False)
    # 配置信息,使用MySQL的JSON类型,非空
    config: Mapped[dict] = mapped_column(JSON, nullable=False)

# 定义LlmModel大模型提供方模型
+class LlmModel(Base):
    # 设置表名
+  __tablename__ = "llm_models"

    # 主键ID,自增
+  id: Mapped[int] = mapped_column(primary_key=True, autoincrement=True)
    # 提供方名称,唯一且有索引
+  provider_name: Mapped[str] = mapped_column(String(255), unique=True, index=True)
    # 图标地址,允许为null
+  provider_icon: Mapped[str | None] = mapped_column(Text, nullable=True)
    # API基础URL,必填
+  api_base_url: Mapped[str] = mapped_column(String(1024), nullable=False)
    # API密钥,必填
+  api_key: Mapped[str] = mapped_column(String(1024), nullable=False)
    # 密钥获取地址,可空
+  api_key_url: Mapped[str | None] = mapped_column(String(1024), nullable=True)
    # 支持的模型名列表,JSON格式,必填
+  model_names: Mapped[list[str]] = mapped_column(JSON, nullable=False)

14.7. schemas.py #

app/schemas.py

# 导入枚举类型Enum
from enum import Enum
# 导入Any类型用于类型注解
from typing import Any
# 从pydantic导入基模型BaseModel、字段类型Field、字段校验器field_validator
from pydantic import BaseModel, Field, field_validator

# 定义MCP协议枚举类型
class McpProtocol(str, Enum):
    # 定义stdio协议
    stdio = "stdio"
    # 定义streamable-http协议
    streamable_http = "streamable-http"
    # 定义sse协议
    sse = "sse"

# 定义MCP服务基础模型
class McpServiceBase(BaseModel):
    # 服务名称,字符串类型,必填,长度1~255
    name: str = Field(..., min_length=1, max_length=255)
    # 服务描述,字符串类型,可为空
    description: str | None = None
    # 协议字段,采用McpProtocol枚举
    protocol: McpProtocol
    # 配置信息,类型为字符串键到任意类型的字典
    config: dict[str, Any]

    # 对config字段添加验证器,保证其为字典类型
    @field_validator("config", mode="before")
    @classmethod
    def config_not_empty(cls, v: Any) -> Any:
        # 如果config不是dict类型,则抛出异常
        if not isinstance(v, dict):
            raise ValueError("config必须是JSON对象")
        # 返回config
        return v

# 定义MCP服务创建模型,继承McpServiceBase
class McpServiceCreate(McpServiceBase):
    pass

# 定义MCP服务更新模型
class McpServiceUpdate(BaseModel):
    # 名称,可选,长度1~255
    name: str | None = Field(None, min_length=1, max_length=255)
    # 描述,可选
    description: str | None = None
    # 协议,可选
    protocol: McpProtocol | None = None
    # 配置信息,可选
    config: dict[str, Any] | None = None

# 定义MCP服务输出模型类
class McpServiceOut(BaseModel):
    # MCP服务器的ID
    id: int
    # MCP服务器的名称
    name: str
    # MCP服务器描述
    description: str | None
    # MCP服务器的协议 strreamable-http sse stdio
    protocol: str
    # MCP服务器的配置信息,是字典类型
    config: dict[str, Any]
    # 设置模型配置,允许从ORM对象属性直接读取数据
    model_config = {
        "from_attributes": True
    }

# 定义MCP测试请求模型
class McpTestRequest(BaseModel):
    # 协议类型
    protocol: McpProtocol
    # 配置信息,必为dict
    config: dict[str, Any]

    # 对config字段进行验证,必须为字典
    @field_validator("config", mode="before")
    @classmethod
    def config_is_object(cls, v: Any):
        # 如果不是dict类型,抛出异常
        if not isinstance(v, dict):
            raise ValueError("config字段的值必须是JSON对象")
        # 返回config
        return v

# 定义MCP测试响应类型
class McpTestResult(BaseModel):
    # 是否成功
    ok: bool
    # 消息内容
    message: str
    # 工具列表,默认为空列表
    tools: list[dict[str, Any]] = Field(default_factory=list)

# 定义LlmModelBase基类,用于存储大模型相关信息
+class LlmModelBase(BaseModel):
    # 提供商名称,必填,最小长度1,最大长度255
+  provider_name: str = Field(..., min_length=1, max_length=255)
    # 提供商图标,可选
+  provider_icon: str | None = None
    # API基础地址,必填,最小长度1,最大长度1024
+  api_base_url: str = Field(..., min_length=1, max_length=1024)
    # API密钥,必填,最小长度1,最大长度1024
+  api_key: str = Field(..., min_length=1, max_length=1024)
    # API密钥申请地址,可选,最大长度1024
+  api_key_url: str | None = Field(None, max_length=1024)
    # 模型名称列表,默认为空列表
+  model_names: list[str] = Field(default_factory=list)

    # 对model_names字段进行校验和归一化处理
+  @field_validator("model_names")
+  @classmethod
+  def normalize_model_names(cls, v: list[str]) -> list[str]:
        # 定义输出列表
+      out: list[str] = []
        # 用于存储已经出现过的模型名称(小写形式)以去重
+      seen: set[str] = set()
        # 遍历输入值,如果为空则用空列表
+      for item in v or []:
            # 将每个名称转为字符串并去除首尾空格
+          name = str(item or "").strip()
            # 如果名称为空,则跳过
+          if not name:
+              continue
            # 转为小写做去重key
+          key = name.lower()
            # 如果已经见过该名称,则跳过
+          if key in seen:
+              continue
            # 添加到已见集合和输出列表
+          seen.add(key)
+          out.append(name)
        # 返回归一化后的模型名称列表
+      return out

# 定义LlmModelCreate模型,继承自LlmModelBase,没有扩展字段
+class LlmModelCreate(LlmModelBase):
+  pass   

# 定义 LlmModelOut 类,继承自 BaseModel,用于大模型的返回数据结构
+class LlmModelOut(BaseModel):
    # 唯一ID,类型为整数
+  id: int
    # 提供商名称,字符串类型
+  provider_name: str
    # 提供商图标,可选,字符串或None
+  provider_icon: str | None
    # API 基础地址,字符串类型
+  api_base_url: str
    # API 密钥,字符串类型
+  api_key: str
    # API 密钥申请地址,可选,字符串或None
+  api_key_url: str | None
    # 模型名称列表,字符串列表类型
+  model_names: list[str]

    # Pydantic 配置,启用从属性赋值(用于ORM模式)
+  model_config = {"from_attributes": True}

# 定义用于大模型服务测试请求体的Pydantic模型
+class LlmModelTestRequest(BaseModel):
    # API基础地址,必须为非空字符串,最大长度1024
+  api_base_url: str = Field(..., min_length=1, max_length=1024)
    # API密钥,必须为非空字符串,最大长度1024
+  api_key: str = Field(..., min_length=1, max_length=1024)

# 定义 LlmModelTestResult 类,继承自 BaseModel,用于返回大模型服务检测的结果
+class LlmModelTestResult(BaseModel):
    # 检测是否通过,布尔类型
+  ok: bool
    # 检测的信息提示,字符串类型
+  message: str
    # 检测到的模型名称列表,默认为空列表
+  models: list[str] = Field(default_factory=list)    

+class UploadImageResult(BaseModel):
+   url: str

14.8. 测试 #

curl --location --request POST "http://127.0.0.1:8000/api/llm-models" ^
--header "Content-Type: application/json" ^
--data-raw "{    \"provider_name\": \"深度探索\",    \"provider_icon\": \"/uploads/08b305a11707447696aecc2040f96ecc.png\",    \"api_base_url\": \"https://api.deepseek.com\",    \"api_key\": \"sk-24088156e9ab48f3adddaf5a9c0c4ede\",    \"api_key_url\": \"https://platform.deepseek.com/api_keys\",    \"model_names\": [        \"deepseek-chat\",        \"deepseek-reasoner\"    ]}"

14.9. 数据 #

14.9.1 API 地址 #

https://api.deepseek.com

14.9.2 LOGO #

14.9.3 API 密钥 #

sk-24088156e9ab48f3adddaf5a9c0c4ede

14.9.4 获取密钥地址 #

https://platform.deepseek.com/api_keys

14.9.5 模型名称 #

deepseek-chat
deepseek-reasoner

14.10 上传图片时序图 #

sequenceDiagram autonumber participant Client as 客户端 participant FastAPI as FastAPI框架 participant Handler as upload_image participant UF as UploadFile对象 participant CFG as settings_upload_path participant FS as 本地磁盘 Client->>FastAPI: POST multipart 上传图片 FastAPI->>Handler: 注入 UploadFile 参数 Handler->>Handler: 解析 Content-Type 取分号前小写 alt 非 JPEG PNG GIF WebP Handler-->>Client: 400 只支持四类图片 else 类型合法 Handler->>Handler: 查表得到扩展名 ext Handler->>UF: await read 读取完整字节 UF-->>Handler: body 字节串 alt 超过 5MB Handler-->>Client: 400 图片不能超过 5MB else 内容为空 Handler-->>Client: 400 空文件 else 校验通过 Handler->>Handler: uuid4 hex 拼接 ext 得文件名 Handler->>CFG: upload_path 取上传根目录 CFG-->>Handler: Path 对象 Handler->>FS: write_bytes 写入目标路径 FS-->>Handler: 写入完成 Handler-->>FastAPI: UploadImageResult FastAPI-->>Client: 200 JSON url 为 uploads 静态路径 end end

14.11 检测大模型时序图 #

sequenceDiagram autonumber participant Client as 客户端 participant FastAPI as FastAPI框架 participant TMS as test_model_service participant UJ as urljoin participant HX as httpx_Client participant API as 远端模型列表服务 participant EXT as extract_model_names Client->>FastAPI: POST probe 携带 api_base_url 与 api_key FastAPI->>TMS: 调用处理函数 payload TMS->>TMS: 去除 base_url 与 api_key 首尾空白 TMS->>UJ: 在规范化后的根地址后拼接 models 路径 UJ-->>TMS: models_url 完整地址 TMS->>TMS: 组装 Authorization Bearer 请求头 alt 请求过程发生异常 Note over TMS,HX: 超时 HTTP 状态错误 或其它错误 见下方说明 else 主流程 try 成功 TMS->>HX: 创建客户端 超时 20 秒 带请求头 TMS->>HX: GET models_url HX->>API: HTTP GET 拉取模型列表 API-->>HX: 响应体 HX-->>TMS: Response TMS->>TMS: raise_for_status 非 2xx 则抛 HTTPStatusError TMS->>TMS: json 解析为字典或失败则走通用异常 TMS->>EXT: 从 body 的 data 列表提取 id 去重 EXT-->>TMS: 模型名称列表 names alt names 非空 TMS-->>FastAPI: ok 真 附带模型数量与名称列表 else names 为空 TMS-->>FastAPI: ok 真 提示未识别到模型列表 空数组 end FastAPI-->>Client: 200 LlmModelTestResult end

14.12 添加大模型时序图 #

sequenceDiagram autonumber participant Client as 客户端 participant FastAPI as FastAPI框架 participant Dep as get_session participant CM as create_model participant Repo as create_llm_model participant ORM as SQLAlchemy会话 participant DB as 数据库 Client->>FastAPI: POST 空路径 创建 LLM 配置 FastAPI->>Dep: 依赖注入 获取数据库 Session Dep-->>FastAPI: session FastAPI->>CM: create_model payload session CM->>Repo: 传入 session 与 LlmModelCreate Repo->>Repo: 构造 LlmModel 行 字段 strip 等 Repo->>ORM: add 新行 Repo->>ORM: commit 提交事务 ORM->>DB: INSERT 等 SQL alt 唯一约束等冲突 DB-->>ORM: IntegrityError ORM-->>Repo: 异常向上抛 Repo-->>CM: IntegrityError CM->>ORM: rollback 回滚 CM-->>FastAPI: HTTPException 409 提供商名称已存在 FastAPI-->>Client: 409 错误响应 else 提交成功 DB-->>ORM: 成功 ORM-->>Repo: 正常返回 Repo->>ORM: refresh 刷新实例取库生成字段 Repo-->>CM: LlmModel 实例 CM-->>FastAPI: 返回 ORM 对象 FastAPI-->>Client: 200 序列化为 LlmModelOut end

15. 大模型列表 #

本节介绍如何通过 API 快速获取和管理系统内已注册的大模型(LLM)列表,便于查看和维护多模型接入情况。

能力说明

  • 支持通过 RESTful API 查询全部已注册大模型配置,返回包含提供商、API 地址、密钥信息、支持的模型名称等关键信息的列表。
  • 可在管理后台或集成环境中动态展示与维护已添加的大模型条目。
  • 响应按新近添加顺序(id 倒序)排列,便于查看最近接入的模型。

API 说明

路由

GET /api/llm-models

请求参数

无

返回结果

返回一个包含所有大模型信息的数组,每个模型字段说明如下:

字段 说明
id 记录自增ID
provider_name 模型提供商名称
provider_icon 提供商 LOGO 图片 URL
api_base_url API 基础地址
api_key 当前存储的 API 密钥
api_key_url 密钥申请网址
model_names 支持的模型名称数组

示例响应:

[
  {
    "id": 1,
    "provider_name": "深度探索",
    "provider_icon": "http://127.0.0.1:8000/uploads/08b305a11707447696aecc2040f96ecc.png",
    "api_base_url": "https://api.deepseek.com",
    "api_key": "sk-xxxxxxx",
    "api_key_url": "https://platform.deepseek.com/api_keys",
    "model_names": [
      "deepseek-chat",
      "deepseek-reasoner"
    ]
  }
]

数据获取范例

可通过如下 curl 命令请求大模型列表:

curl --location --request GET "http://127.0.0.1:8000/api/llm-models"

返回内容即为模型数组,可直接用于前端界面展示或接口调用。

15.1. llm_repository.py #

app/repositories/llm_repository.py

# 导入SQLAlchemy的Session类,用于数据库会话
from sqlalchemy.orm import Session

# 从app包导入models模块,包含数据库模型
from app import models
# 从app包导入schemas模块,包含数据校验模型
from app import schemas
# 导入select语句,用于构建查询条件
+from sqlalchemy import select
# 定义创建LLM模型记录的函数,接收数据库会话和入参数据,返回新建的LlmModel实例
def create_llm_model(session: Session, data: schemas.LlmModelCreate) -> models.LlmModel:
    # 创建LlmModel数据库对象,剔除多余空白并赋值各字段
    row = models.LlmModel(
        provider_name=data.provider_name.strip(),  # 提供商名称,去除首尾空白
        provider_icon=data.provider_icon,          # 提供商图标
        api_base_url=data.api_base_url.strip(),    # API基础地址,去除首尾空白
        api_key=data.api_key.strip(),              # API密钥,去除首尾空白
        api_key_url=data.api_key_url.strip() if data.api_key_url else None,  # API密钥申请地址,有则去除空白,无则为None
        model_names=data.model_names,              # LLM模型名称列表
    )
    # 添加新纪录到数据库session
    session.add(row)
    # 提交事务保存数据
    session.commit()
    # 刷新session获取新数据
    session.refresh(row)
    # 返回新建的LlmModel对象
    return row


# 定义获取所有大模型记录的函数,传入数据库会话,返回LlmModel对象的列表
+def list_llm_models(session: Session) -> list[models.LlmModel]:
    # 按id倒序查询所有LlmModel记录,转换为列表返回
+  return list(session.scalars(select(models.LlmModel).order_by(models.LlmModel.id.desc())).all())    

15.2. llm_models.py #

app/routers/llm_models.py

# 导入urljoin用于拼接URL
from urllib.parse import urljoin

# 导入httpx库(可用于发送HTTP请求,本文件未用到)
import httpx
# 从fastapi库导入APIRouter用于创建路由,Depends用于依赖注入,HTTPException用于异常处理
from fastapi import APIRouter, Depends, HTTPException
# 从sqlalchemy导入IntegrityError,用于捕获唯一性冲突异常
from sqlalchemy.exc import IntegrityError
# 导入Session会话,用于数据库操作
from sqlalchemy.orm import Session

# 导入schemas定义的Pydantic模型
from app import schemas
# 导入llm_repository,用于大模型数据操作
from app.repositories import llm_repository
# 导入get_session获取数据库依赖
from app.database import get_session

# 定义extract_models函数,用于从响应体中提取模型名称列表
def extract_models(body):
    # 从body字典中获取"data"字段
    data = body.get("data")
    # 初始化输出列表,保存模型名
    out: list[str] = []
    # 初始化集合,用于模型名去重(忽略大小写)
    seen: set[str] = set()
    # 遍历data数组中的每个条目
    for item in data:
        # 获取模型的"id"字段,强制转为字符串并去除首尾空白
        name = str(item.get("id") or "").strip()
        # 如果模型名为空则跳过
        if not name:
            continue
        # 将模型名转为小写,用于去重比对
        key = name.lower()
        # 如果已经在seen集合中,说明重复,跳过
        if key in seen:
            continue
        # 否则,将该模型名(小写)加入去重集合
        seen.add(key)
        # 将原始模型名加入输出列表
        out.append(name)
    # 返回去重后的模型名列表
    return out

# 创建APIRouter实例,设置路由前缀与标签
router = APIRouter(prefix="/api/llm-models", tags=["llm-models"])

# 定义POST类型接口,路径为"",响应模型为LlmModelOut
@router.post("", response_model=schemas.LlmModelOut)
def create_model(payload: schemas.LlmModelCreate, session: Session = Depends(get_session)):
    # 尝试创建大模型记录
    try:
        return llm_repository.create_llm_model(session, payload)
    # 捕获唯一性冲突异常(例如Provider的名字已存在)
    except IntegrityError:
        session.rollback()
        # 抛出HTTP 409异常,提示"提供商名称已存在"
        raise HTTPException(status_code=409, detail="提供商名称已存在")

# 定义路由POST接口 /probe,返回值为LlmModelTestResult
@router.post("/probe", response_model=schemas.LlmModelTestResult)
# 定义测试大模型服务的函数
def test_model_service(payload: schemas.LlmModelTestRequest):
    # 去除api_base_url首尾空白字符
    base_url = payload.api_base_url.strip()
    # 去除api_key首尾空白字符
    api_key = payload.api_key.strip()
    # 构造模型列表接口的URL
    models_url = urljoin(base_url.rstrip("/") + "/", "models")
    # 准备请求头,添加Authorization字段
    headers = {
        "Authorization": f"Bearer {api_key}",
    }
    try:
        # 创建HTTP客户端,设置超时时间和请求头
        with httpx.Client(timeout=20.0, headers=headers) as client:
            # 发送GET请求获取模型列表
            resp = client.get(models_url)
        # 检查HTTP响应状态码,若有异常则抛出
        resp.raise_for_status()
        # 解析响应体为JSON
        body = resp.json()
        # 调用工具函数提取模型名称列表
        names = extract_models(body if isinstance(body, dict) else {})
        # 如果检测到模型名称
        if names:
            # 返回检测通过且包含模型数量和名称的结果
            return schemas.LlmModelTestResult(
                ok=True,
                message=f"模型服务检测通过,可用模型 {len(names)} 个",
                models=names,
            )
        # 如果没检测到模型列表,返回通过但无模型名提示
        return schemas.LlmModelTestResult(
            ok=True,
            message="模型服务检测通过,但未识别到模型列表",
            models=[],
        )
    # 捕获请求超时异常,返回相应错误信息
    except httpx.TimeoutException:
        return schemas.LlmModelTestResult(ok=False, message="请求超时,请检查 API 地址")
    # 捕获HTTP状态异常,返回状态码和响应内容前300字符
    except httpx.HTTPStatusError as e:
        return schemas.LlmModelTestResult(ok=False, message=f"HTTP {e.response.status_code}: {e.response.text[:300]}")
    # 捕获所有其他异常,返回异常信息
    except Exception as e:  # noqa: BLE001
        return schemas.LlmModelTestResult(ok=False, message=f"模型服务检测失败: {e}")        

# 定义GET接口用于获取所有大模型列表,响应为LlmModelOut对象的列表
+@router.get("", response_model=list[schemas.LlmModelOut])
# 声明依赖注入数据库会话db
+def list_models(session: Session = Depends(get_session)):
    # 调用llm_repository中的方法获取所有大模型数据
+  return llm_repository.list_llm_models(session)          

15.3 测试 #

curl --location --request GET "http://127.0.0.1:8000/api/llm-models" ^
--header "Content-Type: application/json"

16. 更新大语言模型 #

在本节中,我们将讲解如何通过接口更新已有大语言模型(LLM)的信息,实现供应商名称、图标、API 基础地址、API 密钥、密钥申请地址及支持的模型名称列表等字段的变更。

接口说明

接口路径:

PUT /api/llm-models/{llm_id}

请求头:

Content-Type: application/json

请求体参数(部分或全部可选):

字段名 类型 说明
provider_name string 供应商名称,长度1-255,唯一。
provider_icon string 供应商图标URL,可为空。
api_base_url string API基础地址,长度1-1024
api_key string API密钥,长度1-1024
api_key_url string API密钥申请地址,最长1024,可为空。
model_names array of string 支持的模型名称列表,可为空或不传,元素自动去重与去空白。

所有字段均可选,只有提交的字段才会被更新,未传递的字段保持原值不变。

响应结果

成功调用后返回更新后的大模型信息对象,结构与创建接口一致。

若出现以下情况,将返回对应错误码和消息:

  • 404:记录不存在
  • 409:供应商名称已被其他记录占用

示例

请求示例:

curl --location --request PUT "http://127.0.0.1:8000/api/llm-models/1" ^
--header "Content-Type: application/json" ^
--data-raw "{
    \"provider_name\": \"深度探索4\",
    \"provider_icon\": \"/uploads/08b305a11707447696aecc2040f96ecc.png\",
    \"api_base_url\": \"https://api.deepseek.com\",
    \"api_key\": \"sk-24088156e9ab48f3adddaf5a9c0c4ede\",
    \"api_key_url\": \"https://platform.deepseek.com/api_keys2\",
    \"model_names\": [
        \"deepseek-chat\",
        \"deepseek-reasoner\"
    ]
}"

成功响应示例:

{
  "id": 1,
  "provider_name": "深度探索4",
  "provider_icon": "/uploads/08b305a11707447696aecc2040f96ecc.png",
  "api_base_url": "https://api.deepseek.com",
  "api_key": "sk-24088156e9ab48f3adddaf5a9c0c4ede",
  "api_key_url": "https://platform.deepseek.com/api_keys2",
  "model_names": [
    "deepseek-chat",
    "deepseek-reasoner"
  ]
}

常见错误:

  • {"detail":"记录不存在"}(修改不存在的ID)
  • {"detail":"提供商名称已被其他记录使用"}(名称冲突)

16.1. llm_repository.py #

app/repositories/llm_repository.py

# 导入SQLAlchemy的Session类,用于数据库会话
from sqlalchemy.orm import Session

# 从app包导入models模块,包含数据库模型
from app import models
# 从app包导入schemas模块,包含数据校验模型
from app import schemas
# 导入select语句,用于构建查询条件
from sqlalchemy import select
# 定义创建LLM模型记录的函数,接收数据库会话和入参数据,返回新建的LlmModel实例
def create_llm_model(session: Session, data: schemas.LlmModelCreate) -> models.LlmModel:
    # 创建LlmModel数据库对象,剔除多余空白并赋值各字段
    row = models.LlmModel(
        provider_name=data.provider_name.strip(),  # 提供商名称,去除首尾空白
        provider_icon=data.provider_icon,          # 提供商图标
        api_base_url=data.api_base_url.strip(),    # API基础地址,去除首尾空白
        api_key=data.api_key.strip(),              # API密钥,去除首尾空白
        api_key_url=data.api_key_url.strip() if data.api_key_url else None,  # API密钥申请地址,有则去除空白,无则为None
        model_names=data.model_names,              # LLM模型名称列表
    )
    # 添加新纪录到数据库session
    session.add(row)
    # 提交事务保存数据
    session.commit()
    # 刷新session获取新数据
    session.refresh(row)
    # 返回新建的LlmModel对象
    return row


# 定义获取所有大模型记录的函数,传入数据库会话,返回LlmModel对象的列表
def list_llm_models(session: Session) -> list[models.LlmModel]:
    # 按id倒序查询所有LlmModel记录,转换为列表返回
   return list(session.scalars(select(models.LlmModel).order_by(models.LlmModel.id.desc())).all())    

# 根据指定的llm_id主键,从数据库中获取对应的LlmModel对象,无则返回None
+def get_llm_model(session: Session, llm_id: int) -> models.LlmModel | None:
+  return session.get(models.LlmModel, llm_id)

# 定义根据提供商名称获取对应 LlmModel 记录的函数
+def get_llm_by_provider_name(session: Session, provider_name: str) -> models.LlmModel | None:
    # 构造查询,通过 provider_name 精确查找对应的 LlmModel,返回首个结果或 None
+  return session.scalar(select(models.LlmModel).where(models.LlmModel.provider_name == provider_name))

# 定义更新 LLM 模型记录的函数,接收数据库会话、待更新行和更新数据
+def update_llm_model(session: Session, row: models.LlmModel, data: schemas.LlmModelUpdate) -> models.LlmModel:
    # 如果 provider_name 不为 None,则更新并去除首尾空白
+  if data.provider_name is not None:
+      row.provider_name = data.provider_name.strip()
    # 如果 provider_icon 不为 None,则直接赋值
+  if data.provider_icon is not None:
+      row.provider_icon = data.provider_icon
    # 如果 api_base_url 不为 None,则更新并去除首尾空白
+  if data.api_base_url is not None:
+      row.api_base_url = data.api_base_url.strip()
    # 如果 api_key 不为 None,则更新并去除首尾空白
+  if data.api_key is not None:
+      row.api_key = data.api_key.strip()
    # 如果 api_key_url 不为 None,则去除首尾空白,否则设为 None
+  if data.api_key_url is not None:
+      row.api_key_url = data.api_key_url.strip() if data.api_key_url else None
    # 如果 model_names 不为 None,则进行赋值
+  if data.model_names is not None:
+      row.model_names = data.model_names
    # 提交数据库事务
+  session.commit()
    # 刷新 session 以获取最新数据
+  session.refresh(row)
    # 返回更新后的模型对象
+  return row    

16.2. llm_models.py #

app/routers/llm_models.py

# 导入urljoin用于拼接URL
from urllib.parse import urljoin

# 导入httpx库(可用于发送HTTP请求,本文件未用到)
import httpx
# 从fastapi库导入APIRouter用于创建路由,Depends用于依赖注入,HTTPException用于异常处理
from fastapi import APIRouter, Depends, HTTPException
# 从sqlalchemy导入IntegrityError,用于捕获唯一性冲突异常
from sqlalchemy.exc import IntegrityError
# 导入Session会话,用于数据库操作
from sqlalchemy.orm import Session

# 导入schemas定义的Pydantic模型
from app import schemas
# 导入llm_repository,用于大模型数据操作
from app.repositories import llm_repository
# 导入get_session获取数据库依赖
from app.database import get_session

# 定义extract_models函数,用于从响应体中提取模型名称列表
def extract_models(body):
    # 从body字典中获取"data"字段
    data = body.get("data")
    # 初始化输出列表,保存模型名
    out: list[str] = []
    # 初始化集合,用于模型名去重(忽略大小写)
    seen: set[str] = set()
    # 遍历data数组中的每个条目
    for item in data:
        # 获取模型的"id"字段,强制转为字符串并去除首尾空白
        name = str(item.get("id") or "").strip()
        # 如果模型名为空则跳过
        if not name:
            continue
        # 将模型名转为小写,用于去重比对
        key = name.lower()
        # 如果已经在seen集合中,说明重复,跳过
        if key in seen:
            continue
        # 否则,将该模型名(小写)加入去重集合
        seen.add(key)
        # 将原始模型名加入输出列表
        out.append(name)
    # 返回去重后的模型名列表
    return out

# 创建APIRouter实例,设置路由前缀与标签
router = APIRouter(prefix="/api/llm-models", tags=["llm-models"])

# 定义POST类型接口,路径为"",响应模型为LlmModelOut
@router.post("", response_model=schemas.LlmModelOut)
def create_model(payload: schemas.LlmModelCreate, session: Session = Depends(get_session)):
    # 尝试创建大模型记录
    try:
        return llm_repository.create_llm_model(session, payload)
    # 捕获唯一性冲突异常(例如Provider的名字已存在)
    except IntegrityError:
        session.rollback()
        # 抛出HTTP 409异常,提示"提供商名称已存在"
        raise HTTPException(status_code=409, detail="提供商名称已存在")

# 定义路由POST接口 /probe,返回值为LlmModelTestResult
@router.post("/probe", response_model=schemas.LlmModelTestResult)
# 定义测试大模型服务的函数
def test_model_service(payload: schemas.LlmModelTestRequest):
    # 去除api_base_url首尾空白字符
    base_url = payload.api_base_url.strip()
    # 去除api_key首尾空白字符
    api_key = payload.api_key.strip()
    # 构造模型列表接口的URL
    models_url = urljoin(base_url.rstrip("/") + "/", "models")
    # 准备请求头,添加Authorization字段
    headers = {
        "Authorization": f"Bearer {api_key}",
    }
    try:
        # 创建HTTP客户端,设置超时时间和请求头
        with httpx.Client(timeout=20.0, headers=headers) as client:
            # 发送GET请求获取模型列表
            resp = client.get(models_url)
        # 检查HTTP响应状态码,若有异常则抛出
        resp.raise_for_status()
        # 解析响应体为JSON
        body = resp.json()
        # 调用工具函数提取模型名称列表
        names = extract_models(body if isinstance(body, dict) else {})
        # 如果检测到模型名称
        if names:
            # 返回检测通过且包含模型数量和名称的结果
            return schemas.LlmModelTestResult(
                ok=True,
                message=f"模型服务检测通过,可用模型 {len(names)} 个",
                models=names,
            )
        # 如果没检测到模型列表,返回通过但无模型名提示
        return schemas.LlmModelTestResult(
            ok=True,
            message="模型服务检测通过,但未识别到模型列表",
            models=[],
        )
    # 捕获请求超时异常,返回相应错误信息
    except httpx.TimeoutException:
        return schemas.LlmModelTestResult(ok=False, message="请求超时,请检查 API 地址")
    # 捕获HTTP状态异常,返回状态码和响应内容前300字符
    except httpx.HTTPStatusError as e:
        return schemas.LlmModelTestResult(ok=False, message=f"HTTP {e.response.status_code}: {e.response.text[:300]}")
    # 捕获所有其他异常,返回异常信息
    except Exception as e:  # noqa: BLE001
        return schemas.LlmModelTestResult(ok=False, message=f"模型服务检测失败: {e}")        

# 定义GET接口用于获取所有大模型列表,响应为LlmModelOut对象的列表
@router.get("", response_model=list[schemas.LlmModelOut])
# 声明依赖注入数据库会话db
def list_models(session: Session = Depends(get_session)):
    # 调用llm_repository中的方法获取所有大模型数据
   return llm_repository.list_llm_models(session)          

# 定义PUT接口用于更新指定ID的大模型记录,响应为LlmModelOut对象
+@router.put("/{llm_id}", response_model=schemas.LlmModelOut)
# 定义视图函数,llm_id为要更新的模型ID,payload为更新内容,db为数据库会话(依赖注入)
+def update_model(llm_id: int, payload: schemas.LlmModelUpdate, session: Session = Depends(get_session)):
    # 根据llm_id查询对应的模型记录
+  row = llm_repository.get_llm_model(session, llm_id)
    # 如果没有查到该记录,则返回404异常
+  if not row:
+      raise HTTPException(status_code=404, detail="记录不存在")
    # 如果提交的provider_name不为None,则检查唯一性
+  if payload.provider_name is not None:
        # 根据去空格后的provider_name查找数据库中是否存在其他重名记录
+      other = llm_repository.get_llm_by_provider_name(session, payload.provider_name.strip())
        # 如果找到的记录不是当前要更新的这条,则冲突
+      if other and other.id != llm_id:
+          raise HTTPException(status_code=409, detail="提供商名称已被其他记录使用")
+  try:
        # 调用仓库层方法执行数据库更新,返回更新后的对象
+      return llm_repository.update_llm_model(session, row, payload)
+  except IntegrityError:
        # 捕获唯一性约束冲突,回滚事务并返回409错误
+      session.rollback()
+      raise HTTPException(status_code=409, detail="提供商名称冲突")       

16.3. schemas.py #

app/schemas.py

# 导入枚举类型Enum
from enum import Enum
# 导入Any类型用于类型注解
from typing import Any
# 从pydantic导入基模型BaseModel、字段类型Field、字段校验器field_validator
from pydantic import BaseModel, Field, field_validator

# 定义MCP协议枚举类型
class McpProtocol(str, Enum):
    # 定义stdio协议
    stdio = "stdio"
    # 定义streamable-http协议
    streamable_http = "streamable-http"
    # 定义sse协议
    sse = "sse"

# 定义MCP服务基础模型
class McpServiceBase(BaseModel):
    # 服务名称,字符串类型,必填,长度1~255
    name: str = Field(..., min_length=1, max_length=255)
    # 服务描述,字符串类型,可为空
    description: str | None = None
    # 协议字段,采用McpProtocol枚举
    protocol: McpProtocol
    # 配置信息,类型为字符串键到任意类型的字典
    config: dict[str, Any]

    # 对config字段添加验证器,保证其为字典类型
    @field_validator("config", mode="before")
    @classmethod
    def config_not_empty(cls, v: Any) -> Any:
        # 如果config不是dict类型,则抛出异常
        if not isinstance(v, dict):
            raise ValueError("config必须是JSON对象")
        # 返回config
        return v

# 定义MCP服务创建模型,继承McpServiceBase
class McpServiceCreate(McpServiceBase):
    pass

# 定义MCP服务更新模型
class McpServiceUpdate(BaseModel):
    # 名称,可选,长度1~255
    name: str | None = Field(None, min_length=1, max_length=255)
    # 描述,可选
    description: str | None = None
    # 协议,可选
    protocol: McpProtocol | None = None
    # 配置信息,可选
    config: dict[str, Any] | None = None

# 定义MCP服务输出模型类
class McpServiceOut(BaseModel):
    # MCP服务器的ID
    id: int
    # MCP服务器的名称
    name: str
    # MCP服务器描述
    description: str | None
    # MCP服务器的协议 strreamable-http sse stdio
    protocol: str
    # MCP服务器的配置信息,是字典类型
    config: dict[str, Any]
    # 设置模型配置,允许从ORM对象属性直接读取数据
    model_config = {
        "from_attributes": True
    }

# 定义MCP测试请求模型
class McpTestRequest(BaseModel):
    # 协议类型
    protocol: McpProtocol
    # 配置信息,必为dict
    config: dict[str, Any]

    # 对config字段进行验证,必须为字典
    @field_validator("config", mode="before")
    @classmethod
    def config_is_object(cls, v: Any):
        # 如果不是dict类型,抛出异常
        if not isinstance(v, dict):
            raise ValueError("config字段的值必须是JSON对象")
        # 返回config
        return v

# 定义MCP测试响应类型
class McpTestResult(BaseModel):
    # 是否成功
    ok: bool
    # 消息内容
    message: str
    # 工具列表,默认为空列表
    tools: list[dict[str, Any]] = Field(default_factory=list)

# 定义LlmModelBase基类,用于存储大模型相关信息
class LlmModelBase(BaseModel):
    # 提供商名称,必填,最小长度1,最大长度255
   provider_name: str = Field(..., min_length=1, max_length=255)
    # 提供商图标,可选
   provider_icon: str | None = None
    # API基础地址,必填,最小长度1,最大长度1024
   api_base_url: str = Field(..., min_length=1, max_length=1024)
    # API密钥,必填,最小长度1,最大长度1024
   api_key: str = Field(..., min_length=1, max_length=1024)
    # API密钥申请地址,可选,最大长度1024
   api_key_url: str | None = Field(None, max_length=1024)
    # 模型名称列表,默认为空列表
   model_names: list[str] = Field(default_factory=list)

    # 对model_names字段进行校验和归一化处理
   @field_validator("model_names")
   @classmethod
   def normalize_model_names(cls, v: list[str]) -> list[str]:
        # 定义输出列表
       out: list[str] = []
        # 用于存储已经出现过的模型名称(小写形式)以去重
       seen: set[str] = set()
        # 遍历输入值,如果为空则用空列表
       for item in v or []:
            # 将每个名称转为字符串并去除首尾空格
           name = str(item or "").strip()
            # 如果名称为空,则跳过
           if not name:
               continue
            # 转为小写做去重key
           key = name.lower()
            # 如果已经见过该名称,则跳过
           if key in seen:
               continue
            # 添加到已见集合和输出列表
           seen.add(key)
           out.append(name)
        # 返回归一化后的模型名称列表
       return out

# 定义LlmModelCreate模型,继承自LlmModelBase,没有扩展字段
class LlmModelCreate(LlmModelBase):
   pass   

# 定义 LlmModelOut 类,继承自 BaseModel,用于大模型的返回数据结构
class LlmModelOut(BaseModel):
    # 唯一ID,类型为整数
   id: int
    # 提供商名称,字符串类型
   provider_name: str
    # 提供商图标,可选,字符串或None
   provider_icon: str | None
    # API 基础地址,字符串类型
   api_base_url: str
    # API 密钥,字符串类型
   api_key: str
    # API 密钥申请地址,可选,字符串或None
   api_key_url: str | None
    # 模型名称列表,字符串列表类型
   model_names: list[str]

    # Pydantic 配置,启用从属性赋值(用于ORM模式)
   model_config = {"from_attributes": True}

# 定义用于大模型服务测试请求体的Pydantic模型
class LlmModelTestRequest(BaseModel):
    # API基础地址,必须为非空字符串,最大长度1024
   api_base_url: str = Field(..., min_length=1, max_length=1024)
    # API密钥,必须为非空字符串,最大长度1024
   api_key: str = Field(..., min_length=1, max_length=1024)

# 定义 LlmModelTestResult 类,继承自 BaseModel,用于返回大模型服务检测的结果
class LlmModelTestResult(BaseModel):
    # 检测是否通过,布尔类型
   ok: bool
    # 检测的信息提示,字符串类型
   message: str
    # 检测到的模型名称列表,默认为空列表
   models: list[str] = Field(default_factory=list)    

# 定义用于更新大模型信息的Pydantic模型
+class LlmModelUpdate(BaseModel):
    # 提供商名称,可选字段,限定最小长度1,最大长度255
+  provider_name: str | None = Field(None, min_length=1, max_length=255)
    # 提供商图标,可选字段
+  provider_icon: str | None = None
    # API 基础地址,可选字段,限定最小长度1,最大长度1024
+  api_base_url: str | None = Field(None, min_length=1, max_length=1024)
    # API 密钥,可选字段,限定最小长度1,最大长度1024
+  api_key: str | None = Field(None, min_length=1, max_length=1024)
    # API 密钥申请地址,可选字段,最大长度1024
+  api_key_url: str | None = Field(None, max_length=1024)
    # 模型名称列表,可选字段
+  model_names: list[str] | None = None

    # 对model_names字段做校验和归一化处理
+  @field_validator("model_names")
+  @classmethod
+  def normalize_model_names_optional(cls, v: list[str] | None) -> list[str] | None:
        # 如果为None,直接返回None
+      if v is None:
+          return None
        # 用于存储归一化后的模型名称
+      out: list[str] = []
        # 用于去重
+      seen: set[str] = set()
        # 遍历输入列表
+      for item in v:
            # 转为字符串并去除首尾空白
+          name = str(item or "").strip()
            # 如果名称为空则跳过
+          if not name:
+              continue
            # 使用小写做去重key
+          key = name.lower()
            # 如果已经见过则跳过
+          if key in seen:
+              continue
            # 添加进已见集合
+          seen.add(key)
            # 添加进输出列表
+          out.append(name)
        # 返回归一化去重后的列表
+      return out       

16.4.测试 #

curl --location --request PUT "http://127.0.0.1:8000/api/llm-models/1" ^
--header "Content-Type: application/json" ^
--data-raw "{    \"provider_name\": \"深度探索4\",    \"provider_icon\": \"/uploads/08b305a11707447696aecc2040f96ecc.png\",    \"api_base_url\": \"https://api.deepseek.com\",    \"api_key\": \"sk-24088156e9ab48f3adddaf5a9c0c4ede\",    \"api_key_url\": \"https://platform.deepseek.com/api_keys2\",    \"model_names\": [        \"deepseek-chat\",        \"deepseek-reasoner\"    ]}"

17. 删除大语言模型 #

本节将介绍如何通过 API 删除已有的大语言模型(LLM)记录,包括相关接口说明、使用方法和注意事项。

后端实现说明

删除大语言模型的实现涉及到两部分代码改动:

  • 仓库层(app/repositories/llm_repository.py)
    新增 delete_llm_model 方法,传入数据库会话和要删除的模型对象,完成模型删除和事务提交。

  • 路由层(app/routers/llm_models.py)
    新增 DELETE /api/llm-models/{llm_id} 接口:

    1. 首先根据 llm_id 查询待删除对象;
    2. 如果对象不存在,返回 404;
    3. 调用仓库删除方法成功后返回 { "ok": true }。

接口使用方法

请求说明

  • 接口路径:DELETE /api/llm-models/{llm_id}
  • 路径参数:llm_id 需要删除的模型ID
  • 请求体:无
  • 响应结果:
    • 删除成功:{ "ok": true }
    • 若ID不存在:HTTP 404 Not Found

示例命令

curl --location --request DELETE "http://127.0.0.1:8000/api/llm-models/2" ^
--header "Content-Type: application/json"

可能返回值

  • 成功

    { "ok": true }
  • 记录不存在

    {
      "detail": "记录不存在"
    }

17.1. llm_repository.py #

app/repositories/llm_repository.py

# 导入SQLAlchemy的Session类,用于数据库会话
from sqlalchemy.orm import Session

# 从app包导入models模块,包含数据库模型
from app import models
# 从app包导入schemas模块,包含数据校验模型
from app import schemas
# 导入select语句,用于构建查询条件
from sqlalchemy import select
# 定义创建LLM模型记录的函数,接收数据库会话和入参数据,返回新建的LlmModel实例
def create_llm_model(session: Session, data: schemas.LlmModelCreate) -> models.LlmModel:
    # 创建LlmModel数据库对象,剔除多余空白并赋值各字段
    row = models.LlmModel(
        provider_name=data.provider_name.strip(),  # 提供商名称,去除首尾空白
        provider_icon=data.provider_icon,          # 提供商图标
        api_base_url=data.api_base_url.strip(),    # API基础地址,去除首尾空白
        api_key=data.api_key.strip(),              # API密钥,去除首尾空白
        api_key_url=data.api_key_url.strip() if data.api_key_url else None,  # API密钥申请地址,有则去除空白,无则为None
        model_names=data.model_names,              # LLM模型名称列表
    )
    # 添加新纪录到数据库session
    session.add(row)
    # 提交事务保存数据
    session.commit()
    # 刷新session获取新数据
    session.refresh(row)
    # 返回新建的LlmModel对象
    return row


# 定义获取所有大模型记录的函数,传入数据库会话,返回LlmModel对象的列表
def list_llm_models(session: Session) -> list[models.LlmModel]:
    # 按id倒序查询所有LlmModel记录,转换为列表返回
   return list(session.scalars(select(models.LlmModel).order_by(models.LlmModel.id.desc())).all())    

# 根据指定的llm_id主键,从数据库中获取对应的LlmModel对象,无则返回None
def get_llm_model(session: Session, llm_id: int) -> models.LlmModel | None:
   return session.get(models.LlmModel, llm_id)

# 定义根据提供商名称获取对应 LlmModel 记录的函数
def get_llm_by_provider_name(session: Session, provider_name: str) -> models.LlmModel | None:
    # 构造查询,通过 provider_name 精确查找对应的 LlmModel,返回首个结果或 None
   return session.scalar(select(models.LlmModel).where(models.LlmModel.provider_name == provider_name))

# 定义更新 LLM 模型记录的函数,接收数据库会话、待更新行和更新数据
def update_llm_model(session: Session, row: models.LlmModel, data: schemas.LlmModelUpdate) -> models.LlmModel:
    # 如果 provider_name 不为 None,则更新并去除首尾空白
   if data.provider_name is not None:
       row.provider_name = data.provider_name.strip()
    # 如果 provider_icon 不为 None,则直接赋值
   if data.provider_icon is not None:
       row.provider_icon = data.provider_icon
    # 如果 api_base_url 不为 None,则更新并去除首尾空白
   if data.api_base_url is not None:
       row.api_base_url = data.api_base_url.strip()
    # 如果 api_key 不为 None,则更新并去除首尾空白
   if data.api_key is not None:
       row.api_key = data.api_key.strip()
    # 如果 api_key_url 不为 None,则去除首尾空白,否则设为 None
   if data.api_key_url is not None:
       row.api_key_url = data.api_key_url.strip() if data.api_key_url else None
    # 如果 model_names 不为 None,则进行赋值
   if data.model_names is not None:
       row.model_names = data.model_names
    # 提交数据库事务
   session.commit()
    # 刷新 session 以获取最新数据
   session.refresh(row)
    # 返回更新后的模型对象
   return row    

# 定义删除 LLM 模型记录的函数,接收数据库会话和要删除的模型行
+def delete_llm_model(session: Session, row: models.LlmModel) -> None:
    # 从数据库会话中删除指定的模型对象
+  session.delete(row)
    # 提交事务,执行删除操作
+  session.commit()

17.2. llm_models.py #

app/routers/llm_models.py

# 导入urljoin用于拼接URL
from urllib.parse import urljoin

# 导入httpx库(可用于发送HTTP请求,本文件未用到)
import httpx
# 从fastapi库导入APIRouter用于创建路由,Depends用于依赖注入,HTTPException用于异常处理
from fastapi import APIRouter, Depends, HTTPException
# 从sqlalchemy导入IntegrityError,用于捕获唯一性冲突异常
from sqlalchemy.exc import IntegrityError
# 导入Session会话,用于数据库操作
from sqlalchemy.orm import Session

# 导入schemas定义的Pydantic模型
from app import schemas
# 导入llm_repository,用于大模型数据操作
from app.repositories import llm_repository
# 导入get_session获取数据库依赖
from app.database import get_session

# 定义extract_models函数,用于从响应体中提取模型名称列表
def extract_models(body):
    # 从body字典中获取"data"字段
    data = body.get("data")
    # 初始化输出列表,保存模型名
    out: list[str] = []
    # 初始化集合,用于模型名去重(忽略大小写)
    seen: set[str] = set()
    # 遍历data数组中的每个条目
    for item in data:
        # 获取模型的"id"字段,强制转为字符串并去除首尾空白
        name = str(item.get("id") or "").strip()
        # 如果模型名为空则跳过
        if not name:
            continue
        # 将模型名转为小写,用于去重比对
        key = name.lower()
        # 如果已经在seen集合中,说明重复,跳过
        if key in seen:
            continue
        # 否则,将该模型名(小写)加入去重集合
        seen.add(key)
        # 将原始模型名加入输出列表
        out.append(name)
    # 返回去重后的模型名列表
    return out

# 创建APIRouter实例,设置路由前缀与标签
router = APIRouter(prefix="/api/llm-models", tags=["llm-models"])

# 定义POST类型接口,路径为"",响应模型为LlmModelOut
@router.post("", response_model=schemas.LlmModelOut)
def create_model(payload: schemas.LlmModelCreate, session: Session = Depends(get_session)):
    # 尝试创建大模型记录
    try:
        return llm_repository.create_llm_model(session, payload)
    # 捕获唯一性冲突异常(例如Provider的名字已存在)
    except IntegrityError:
        session.rollback()
        # 抛出HTTP 409异常,提示"提供商名称已存在"
        raise HTTPException(status_code=409, detail="提供商名称已存在")

# 定义路由POST接口 /probe,返回值为LlmModelTestResult
@router.post("/probe", response_model=schemas.LlmModelTestResult)
# 定义测试大模型服务的函数
def test_model_service(payload: schemas.LlmModelTestRequest):
    # 去除api_base_url首尾空白字符
    base_url = payload.api_base_url.strip()
    # 去除api_key首尾空白字符
    api_key = payload.api_key.strip()
    # 构造模型列表接口的URL
    models_url = urljoin(base_url.rstrip("/") + "/", "models")
    # 准备请求头,添加Authorization字段
    headers = {
        "Authorization": f"Bearer {api_key}",
    }
    try:
        # 创建HTTP客户端,设置超时时间和请求头
        with httpx.Client(timeout=20.0, headers=headers) as client:
            # 发送GET请求获取模型列表
            resp = client.get(models_url)
        # 检查HTTP响应状态码,若有异常则抛出
        resp.raise_for_status()
        # 解析响应体为JSON
        body = resp.json()
        # 调用工具函数提取模型名称列表
        names = extract_models(body if isinstance(body, dict) else {})
        # 如果检测到模型名称
        if names:
            # 返回检测通过且包含模型数量和名称的结果
            return schemas.LlmModelTestResult(
                ok=True,
                message=f"模型服务检测通过,可用模型 {len(names)} 个",
                models=names,
            )
        # 如果没检测到模型列表,返回通过但无模型名提示
        return schemas.LlmModelTestResult(
            ok=True,
            message="模型服务检测通过,但未识别到模型列表",
            models=[],
        )
    # 捕获请求超时异常,返回相应错误信息
    except httpx.TimeoutException:
        return schemas.LlmModelTestResult(ok=False, message="请求超时,请检查 API 地址")
    # 捕获HTTP状态异常,返回状态码和响应内容前300字符
    except httpx.HTTPStatusError as e:
        return schemas.LlmModelTestResult(ok=False, message=f"HTTP {e.response.status_code}: {e.response.text[:300]}")
    # 捕获所有其他异常,返回异常信息
    except Exception as e:  # noqa: BLE001
        return schemas.LlmModelTestResult(ok=False, message=f"模型服务检测失败: {e}")        

# 定义GET接口用于获取所有大模型列表,响应为LlmModelOut对象的列表
@router.get("", response_model=list[schemas.LlmModelOut])
# 声明依赖注入数据库会话db
def list_models(session: Session = Depends(get_session)):
    # 调用llm_repository中的方法获取所有大模型数据
   return llm_repository.list_llm_models(session)          

# 定义PUT接口用于更新指定ID的大模型记录,响应为LlmModelOut对象
@router.put("/{llm_id}", response_model=schemas.LlmModelOut)
# 定义视图函数,llm_id为要更新的模型ID,payload为更新内容,db为数据库会话(依赖注入)
def update_model(llm_id: int, payload: schemas.LlmModelUpdate, session: Session = Depends(get_session)):
    # 根据llm_id查询对应的模型记录
   row = llm_repository.get_llm_model(session, llm_id)
    # 如果没有查到该记录,则返回404异常
   if not row:
       raise HTTPException(status_code=404, detail="记录不存在")
    # 如果提交的provider_name不为None,则检查唯一性
   if payload.provider_name is not None:
        # 根据去空格后的provider_name查找数据库中是否存在其他重名记录
       other = llm_repository.get_llm_by_provider_name(session, payload.provider_name.strip())
        # 如果找到的记录不是当前要更新的这条,则冲突
       if other and other.id != llm_id:
           raise HTTPException(status_code=409, detail="提供商名称已被其他记录使用")
   try:
        # 调用仓库层方法执行数据库更新,返回更新后的对象
       return llm_repository.update_llm_model(session, row, payload)
   except IntegrityError:
        # 捕获唯一性约束冲突,回滚事务并返回409错误
       session.rollback()
       raise HTTPException(status_code=409, detail="提供商名称冲突")       

# 定义DELETE接口用于删除指定ID的大模型记录
+@router.delete("/{llm_id}")
# 定义删除大模型的函数,接收llm_id参数和数据库会话(依赖注入)
+def delete_model(llm_id: int, session: Session = Depends(get_session)):
    # 根据传入的llm_id从数据库查询对应的模型记录
+  row = llm_repository.get_llm_model(session, llm_id)
    # 如果未查到该记录,则抛出404 Not Found异常
+  if not row:
+      raise HTTPException(status_code=404, detail="记录不存在")
    # 调用仓库层方法删除该模型记录
+  llm_repository.delete_llm_model(session, row)
    # 删除成功后返回ok为True的响应
+  return {"ok": True}               

17.3 测试 #

curl --location --request DELETE "http://127.0.0.1:8000/api/llm-models/2" ^
--header "Content-Type: application/json"

18. 添加查看智能体 #

本节介绍如何在平台中查看已创建的所有智能体(Agent)信息。

功能说明

“查看智能体”功能允许你通过接口一次性获取所有已注册的智能体(如旅游规划助手等)的详细配置信息,便于管理、展示或调试。返回结果包括每个智能体的基础属性(如ID、头像、名称、描述、引擎型号、提示词、参数配置等),适用于管理后台展示或前端下拉选择。

主要请求接口

接口路径:
GET /api/agents

主要用途:

  • 获取所有智能体(Agent)配置信息列表
  • 可用于智能体管理页面、表单智能体选择、参数预览等场景

返回示例

每个智能体返回如下示例字段(字段类型见 AgentOut 响应模型,可根据实际数据库表结构自动序列化):

[
  {
    "id": 1,
    "avatar": "/uploads/b20afbe9728742579b53d003ab7a7008.png",
    "name": "旅游规划智能体",
    "description": "面向中文出行场景的一站式旅游规划助手:可先问清出发地、目的地、行程与偏好...",
    "opening_message": "你好,我是你的旅游规划智能体 👋 ...",
    "system_prompt": "你是“旅游规划智能体”,目标是帮助用户完成从“去哪玩”到“怎么去、天气如何、是否适合出行”的一站式决策...",
    "llm_provider_name": "深度探索",
    "llm_model_name": "deepseek-chat",
    "mcp_service_ids": [5,4,3],
    "ask_prompt_template": "你是一名资深中文旅行规划师。请基于以下用户信息,输出一份可执行、具体、务实的旅行方案...",
    "ask_variables": [
      {"key": "出发地", "label": "出发地", "question": "你从哪个城市出发?", "required": true, "default": ""},
      {"key": "目的地", "label": "目的地", "question": "你要去哪个城市?", "required": true, "default": ""},
      ...
    ]
  },
  ...
]

使用方法

  1. 前端或测试工具(如 curl、Postman)直接向该接口发送 GET 请求。
  2. 服务端返回所有已注册智能体的完整配置信息。
  3. 可结合分页、筛选等业务需求进行扩展。

示例:

curl --location --request GET "http://127.0.0.1:8000/api/agents" ^
--header "Content-Type: application/json"

预期响应

  • 返回 200,数据为 AgentOut 类型的列表
  • 每个智能体均为一条完整的 JSON 记录

相关代码说明

  • 智能体管理相关的数据访问(如 list_agents)已在 app/repositories/agent_repository.py 实现。
  • 路由接口部分见 app/routers/agents.py 文件。
  • 响应序列化结构见 schemas.AgentOut,可根据业务需求灵活调整。

18.1. agent_repository.py #

app/repositories/agent_repository.py

# 导入Session类用于数据库会话管理
from sqlalchemy.orm import Session
from sqlalchemy import select
# 从app模块导入models和schemas
from app import models
from app import schemas

# 定义创建Agent的函数,参数为数据库会话和Agent创建数据,返回创建后的Agent模型
def create_agent(session: Session, data: schemas.AgentCreate) -> models.Agent:
    # 创建Agent模型实例,将前端传入的数据赋值到各个字段
    row = models.Agent(
        avatar=data.avatar,  # 头像
        name=data.name.strip(),  # 名称,去除首尾空格
        description=data.description,  # 描述
        opening_message=(data.opening_message or "").strip() or None,  # 开场白,去除空格,允许为None
        system_prompt=data.system_prompt.strip(),  # 系统提示,去除空格
        llm_provider_name=data.llm_provider_name.strip(),  # LLM提供商名称,去除空格
        llm_model_name=data.llm_model_name.strip(),  # LLM模型名称,去除空格
        mcp_service_ids=data.mcp_service_ids,  # 关联MCP服务ID列表
        ask_prompt_template=(data.ask_prompt_template or "").strip() or None,  # 提问提示词模板,去除空格,允许为None
        ask_variables=data.ask_variables or [],  # 提问变量,默认为空列表
    )
    # 将Agent实例添加到数据库会话
    session.add(row)
    # 提交事务,保存到数据库
    session.commit()
    # 刷新实例,获取数据库生成的最新字段(如自增ID)
    session.refresh(row)
    # 返回创建后的Agent对象
    return row

# 定义list_agents函数,接收一个数据库会话db作为参数,返回Agent对象列表
def list_agents(session: Session) -> list[models.Agent]:
    # 使用select语句查询Agent表,并按id倒序排序,获取所有Agent对象
    return list(session.scalars(select(models.Agent).order_by(models.Agent.id.desc())).all())    

18.2. agents.py #

app/routers/agents.py

# 从 fastapi 导入 APIRouter, Depends 以及 HTTPException 异常
from fastapi import APIRouter, Depends, HTTPException
# 从 sqlalchemy.orm 导入 Session 会话对象
from sqlalchemy.orm import Session

# 从 app 包分别导入 schemas 模块
from app import schemas
# 导入数据库依赖获取函数
from app.database import get_session
# 导入 agent_repository、llm_repository 和 mcp_repository,分别处理不同的数据操作
from app.repositories import agent_repository, llm_repository, mcp_repository

# 创建一个 APIRouter 实例,设置路由的前缀和标签
router = APIRouter(prefix="/api/agents", tags=["agents"])

# 校验相关引用有效性(大模型提供商、模型、MCP 服务)
def _validate_refs(session: Session, provider_name: str, model_name: str, mcp_service_ids: list[int]) -> None:
    # 根据提供商名称查询 LLM 提供商数据
    llm_row = llm_repository.get_llm_by_provider_name(session, provider_name.strip())
    # 如果没有找到对应的 LLM 提供商则抛出 HTTP 400 异常
    if not llm_row:
        raise HTTPException(status_code=400, detail=f"大语言模型提供商不存在: {provider_name}")
    # 获取该提供商下的所有模型名,并做字符串清洗
    llm_models = [str(m or "").strip() for m in (llm_row.model_names or [])]
    # 如果传入的模型名称不属于当前提供商的模型,则抛出 HTTP 400 异常
    if model_name.strip() not in llm_models:
        raise HTTPException(status_code=400, detail=f"模型名称不属于提供商 {provider_name}: {model_name}")
    # 遍历所有 mcp_service_ids
    for sid in mcp_service_ids:
        # 校验每一个 mcp_service 是否存在,如果不存在则抛出 400 异常
        if not mcp_repository.get_mcp_service(session, int(sid)):
            raise HTTPException(status_code=400, detail=f"MCP 服务不存在: {sid}")

# 定义创建 agent 的接口,POST 请求,响应体为 AgentOut 模型
@router.post("", response_model=schemas.AgentOut)
def create_agent(payload: schemas.AgentCreate, session: Session = Depends(get_session)):
    # 调用 _validate_refs 校验 Agent 创建时关联的 LLM 提供商、模型、MCP 服务是否有效
    _validate_refs(session, payload.llm_provider_name, payload.llm_model_name, payload.mcp_service_ids)
    # 校验通过后,调用 agent_repository 创建新的 Agent,并返回创建结果
    return agent_repository.create_agent(session, payload)

# 定义 GET 接口用于获取所有 Agent 列表,响应为 AgentOut 对象列表
@router.get("", response_model=list[schemas.AgentOut])
# 定义 list_agents 视图函数,依赖注入数据库会话 session
def list_agents(session: Session = Depends(get_session)):
    # 调用 agent_repository 的 list_agents 方法,获取所有 Agent 数据
    return agent_repository.list_agents(session)    

18.3. main.py #

app/main.py

# 导入FastAPI框架
from fastapi import FastAPI

# 导入日志模块
import logging

# 导入异步上下文管理器
from contextlib import asynccontextmanager

# 导入CORS中间件
from fastapi.middleware.cors import CORSMiddleware

# 导入应用配置
from app.config import settings

# 导入数据库模型基类和数据库引擎
from app.database import Base, engine

# 导入所有模型
from app.models import *
+from app.routers import mcp_services, llm_models, uploads,agents

# 导入静态文件中间件
from fastapi.staticfiles import StaticFiles

# 配置日志输出级别为INFO
logging.basicConfig(level=logging.INFO)


# 使用异步上下文管理器定义FastAPI生命周期事件
@asynccontextmanager
async def lifespan(app: FastAPI):
    # 创建所有数据库表
    Base.metadata.create_all(bind=engine)
    # 保持应用运行,等待关闭时执行清理工作
    yield


# 实例化FastAPI应用,指定标题、版本、生命周期管理器
app = FastAPI(title="智能体服务", version="0.1.0", lifespan=lifespan)

# 解析并清洗跨域允许的来源列表
origins = [o.strip() for o in settings.cors_origins if o.strip()]

# 添加跨域中间件,允许指定来源跨域访问
app.add_middleware(
    CORSMiddleware,
    allow_origins=origins,
    allow_credentials=True,
    allow_methods=["*"],
    allow_headers=["*"],
)

# 注册MCP服务相关路由
app.include_router(mcp_services.router)
app.include_router(llm_models.router)
app.include_router(uploads.router)
# 注册智能体相关路由
+app.include_router(agents.router)
# 获取上传文件的目录
upload_root = settings.upload_path()
# 保证upload_root目录存在.如果不存在则创建
upload_root.mkdir(parents=True, exist_ok=True)
# 挂载静态文件目录到uploads目录中
app.mount("/uploads", StaticFiles(directory=str(upload_root)), name="uploads")


# 健康检查接口
@app.get("/health")
def health():
    # 返回服务状态ok
    return {"status": "ok"}

18.4. models.py #

app/models.py

# 导入用于处理日期和时间的datetime模块
from datetime import datetime

# 从SQLAlchemy中导入常用的数据类型和函数
from sqlalchemy import DateTime, String, Text, func

# 导入MySQL方言下的JSON字段类型
from sqlalchemy.dialects.mysql import JSON

# 导入ORM映射相关的类型声明和字段映射函数
from sqlalchemy.orm import Mapped, mapped_column

# 从项目数据库模块导入ORM基类
from app.database import Base


# 定义MCP服务的ORM模型
class McpService(Base):
    # 指定数据库表名为"mcp_services"
    __tablename__ = "mcp_services"
    # 定义主键id字段,自增
    id: Mapped[int] = mapped_column(primary_key=True, autoincrement=True)
    # 服务名称,最大长度255,唯一且有索引
    name: Mapped[str] = mapped_column(String(255), unique=True, index=True)
    # 服务描述,可空,使用Text类型
    description: Mapped[str | None] = mapped_column(Text, nullable=True)
    # 协议字段,最大长度32,非空
    protocol: Mapped[str] = mapped_column(String(32), nullable=False)
    # 配置信息,使用MySQL的JSON类型,非空
    config: Mapped[dict] = mapped_column(JSON, nullable=False)


class LlmModel(Base):
    __tablename__ = "llm_models"
    id: Mapped[int] = mapped_column(primary_key=True, autoincrement=True)
    provider_name: Mapped[str] = mapped_column(String(255), unique=True, index=True)
    provider_icon: Mapped[str | None] = mapped_column(Text, nullable=True)
    api_base_url: Mapped[str] = mapped_column(String(1024), nullable=False)
    api_key: Mapped[str] = mapped_column(String(1024), nullable=False)
    api_key_url: Mapped[str | None] = mapped_column(String(1024), nullable=True)
    model_names: Mapped[list[str]] = mapped_column(JSON, nullable=False)


# 定义Agent智能体模型
class Agent(Base):
    # 表名设置
+  __tablename__ = "agents"

    # 主键、自增
+  id: Mapped[int] = mapped_column(primary_key=True, autoincrement=True)
    # 头像,可空
+  avatar: Mapped[str | None] = mapped_column(Text, nullable=True)
    # 名称,有索引
+  name: Mapped[str] = mapped_column(String(255), index=True)
    # 描述,可空
+  description: Mapped[str | None] = mapped_column(Text, nullable=True)
    # 开场消息,可空
+  opening_message: Mapped[str | None] = mapped_column(Text, nullable=True)
    # 系统提示,必填
+  system_prompt: Mapped[str] = mapped_column(Text, nullable=False)
    # LLM提供方名称,必填
+  llm_provider_name: Mapped[str] = mapped_column(String(255), nullable=False)
    # LLM模型名称,必填
+  llm_model_name: Mapped[str] = mapped_column(String(255), nullable=False)
    # MCP服务ID列表,JSON格式,必填
+  mcp_service_ids: Mapped[list[int]] = mapped_column(JSON, nullable=False)
    # 询问提示词(模板),可空
+  ask_prompt_template: Mapped[str | None] = mapped_column(Text, nullable=True)
    # 询问变量,默认为空列表,JSON格式,不能为空
+  ask_variables: Mapped[list[dict]] = mapped_column(JSON, nullable=False, default=list)  

18.5. schemas.py #

app/schemas.py

# 导入枚举类型Enum
from enum import Enum

# 导入Any类型用于类型注解
from typing import Any

# 从pydantic导入基模型BaseModel、字段类型Field、字段校验器field_validator
from pydantic import BaseModel, Field, field_validator


# 定义MCP协议枚举类型
class McpProtocol(str, Enum):
    # 定义stdio协议
    stdio = "stdio"
    # 定义streamable-http协议
    streamable_http = "streamable-http"
    # 定义sse协议
    sse = "sse"


# 定义MCP服务基础模型
class McpServiceBase(BaseModel):
    # 服务名称,字符串类型,必填,长度1~255
    name: str = Field(..., min_length=1, max_length=255)
    # 服务描述,字符串类型,可为空
    description: str | None = None
    # 协议字段,采用McpProtocol枚举
    protocol: McpProtocol
    # 配置信息,类型为字符串键到任意类型的字典
    config: dict[str, Any]

    # 对config字段添加验证器,保证其为字典类型
    @field_validator("config", mode="before")
    @classmethod
    def config_not_empty(cls, v: Any) -> Any:
        # 如果config不是dict类型,则抛出异常
        if not isinstance(v, dict):
            raise ValueError("config必须是JSON对象")
        # 返回config
        return v


# 定义MCP服务创建模型,继承McpServiceBase
class McpServiceCreate(McpServiceBase):
    pass


# 定义MCP服务更新模型
class McpServiceUpdate(BaseModel):
    # 名称,可选,长度1~255
    name: str | None = Field(None, min_length=1, max_length=255)
    # 描述,可选
    description: str | None = None
    # 协议,可选
    protocol: McpProtocol | None = None
    # 配置信息,可选
    config: dict[str, Any] | None = None


# 定义MCP服务输出模型类
class McpServiceOut(BaseModel):
    # MCP服务器的ID
    id: int
    # MCP服务器的名称
    name: str
    # MCP服务器描述
    description: str | None
    # MCP服务器的协议 strreamable-http sse stdio
    protocol: str
    # MCP服务器的配置信息,是字典类型
    config: dict[str, Any]
    # 设置模型配置,允许从ORM对象属性直接读取数据
    model_config = {"from_attributes": True}


# 定义MCP测试请求模型
class McpTestRequest(BaseModel):
    # 协议类型
    protocol: McpProtocol
    # 配置信息,必为dict
    config: dict[str, Any]

    # 对config字段进行验证,必须为字典
    @field_validator("config", mode="before")
    @classmethod
    def config_is_object(cls, v: Any):
        # 如果不是dict类型,抛出异常
        if not isinstance(v, dict):
            raise ValueError("config字段的值必须是JSON对象")
        # 返回config
        return v


# 定义MCP测试响应类型
class McpTestResult(BaseModel):
    # 是否成功
    ok: bool
    # 消息内容
    message: str
    # 工具列表,默认为空列表
    tools: list[dict[str, Any]] = Field(default_factory=list)


class LlmModelBase(BaseModel):
    provider_name: str = Field(..., min_length=1, max_length=255)
    provider_icon: str | None = None
    api_base_url: str = Field(..., min_length=1, max_length=1024)
    api_key: str = Field(..., min_length=1, max_length=1024)
    api_key_url: str | None = Field(None, max_length=1024)
    model_names: list[str] = Field(default_factory=list)

    # 用来对模型名称进行校验和归一化处理 要求模型名全是小写,不能为空,不能重复
    @field_validator("model_names")
    @classmethod
    def normalize_model_names(cls, v: list[str]) -> list[str]:
        out: list[str] = []
        # 用来对模型名进行去重
        seen: set[str] = set()
        for item in v or []:
            name = str(item or "").strip()
            # 过滤空的模型名
            if not name:
                continue
            # 全是小写
            key = name.lower()
            if key in seen:
                continue
            seen.add(key)
            out.append(name)
        return out


class LlmModelCreate(LlmModelBase):
    pass


class LlmModelUpdate(LlmModelBase):
    pass


class LlmModelOut(BaseModel):
    id: int
    provider_name: str
    provider_icon: str | None
    api_base_url: str
    api_key: str
    api_key_url: str
    model_names: list[str]
    # Pydantic的配置,启用从属性赋值(用于ORM模型) 可以实现从ORM的实例直接默认转成Pydanic类的实例
    model_config = {"from_attributes": True}


class UploadImageResult(BaseModel):
    url: str


class LlmModelTestRequest(BaseModel):
    api_base_url: str = Field(..., min_length=1, max_length=1024)
    api_key: str = Field(..., min_length=1, max_length=1024)


class LlmModelTestResult(BaseModel):
    ok: bool
    messsage: str
    # 检测到的模型名称列表,默认为空列表
    models: list[str] = Field(default_factory=list)


# 定义函数,用于规范化 ask_variables 字段,输入为可选的字典列表,返回规范化后的字典列表
+def normalize_ask_variables(v):
    # 用于保存处理后的变量字典
+ out = []
    # 用于记录已出现过的变量key,实现去重
+ seen = set()
    # 遍历输入列表(如果为None则转为空列表)
+ for item in v or []:
        # 如果当前元素不是字典类型,则跳过
+     if not isinstance(item, dict):
+         continue
        # 从字典中获取key字段,去除首尾空白转字符串
+     key = str(item.get("key") or "").strip()
        # 如果key为空,则跳过本轮
+     if not key:
+         continue
        # 如果key已出现,则跳过实现去重
+     if key in seen:
+         continue
        # 将当前key加入去重集合
+     seen.add(key)
        # 获取question字段、去空白
+     question = str(item.get("question") or "").strip()
        # 获取label字段、去空白
+     label = str(item.get("label") or "").strip()
        # 获取default字段、去空白
+     default_value = str(item.get("default") or "").strip()
        # 获取required字段,默认为True
+     required = bool(item.get("required", True))
        # 将变量信息归一化后放入输出列表
+     out.append(
+         {
+             "key": key,
+             "label": label,
+             "question": question or f"请提供 {label or key}",
+             "required": required,
+             "default": default_value,
+         }
+     )
    # 返回归一化和去重后的变量列表
+ return out


# 定义 AgentBase 基础模型,继承自 Pydantic 的 BaseModel
+class AgentBase(BaseModel):
    # 头像字段,可为空
+ avatar: str | None = None
    # 智能体名称,必填,长度1~255
+ name: str = Field(..., min_length=1, max_length=255)
    # 描述信息,可为空
+ description: str | None = None
    # 开场白内容,可为空
+ opening_message: str | None = None
    # 智能体系统提示,必填,最小长度1
+ system_prompt: str = Field(..., min_length=1)
    # LLM 提供商名称,必填,长度1~255
+ llm_provider_name: str = Field(..., min_length=1, max_length=255)
    # LLM 模型名称,必填,长度1~255
+ llm_model_name: str = Field(..., min_length=1, max_length=255)
    # 关联的 MCP 服务ID列表,默认为空列表
+ mcp_service_ids: list[int] = Field(default_factory=list)
    # 询问提示词模板,可为空
+ ask_prompt_template: str | None = None
    # 询问变量列表,默认为空列表
+ ask_variables: list[dict[str, Any]] = Field(default_factory=list)

    # 对 mcp_service_ids 字段做归一化校验
+ @field_validator("mcp_service_ids")
+ @classmethod
+ def normalize_mcp_service_ids(cls, v):
        # 存储去重后的有效 id
+     out = []
        # 已经出现过的 id 集合
+     seen = set()
        # 遍历 id 列表(防止为 None)
+     for item in v or []:
            # 转成整数类型
+         num = int(item)
            # 跳过小于等于0的无效 id
+         if num <= 0:
+             continue
            # 跳过重复 id
+         if num in seen:
+             continue
            # 添加到去重集合
+         seen.add(num)
            # 添加到输出集合
+         out.append(num)
        # 返回整理后的 id 列表
+     return out

    # 对 ask_variables 字段做归一化校验
+ @field_validator("ask_variables")
+ @classmethod
+ def normalize_ask_variables(cls, v):
        # 使用 _normalize_ask_variables 函数处理
+     return normalize_ask_variables(v)

# 定义 AgentCreate 创建模型,继承自 AgentBase,无额外字段
+class AgentCreate(AgentBase):
+ pass        

# 定义AgentOut响应模型,继承自BaseModel
+class AgentOut(BaseModel):
    # 主键ID
+ id: int
    # 头像,允许为None
+ avatar: str | None
    # 智能体名称
+ name: str
    # 智能体描述,允许为None
+ description: str | None
    # 开场白,允许为None
+ opening_message: str | None
    # 智能体系统提示,不可为None
+ system_prompt: str
    # LLM提供商名称
+ llm_provider_name: str
    # LLM模型名称
+ llm_model_name: str
    # 关联的MCP服务ID列表
+ mcp_service_ids: list[int]
    # 询问提示词模板,允许为None
+ ask_prompt_template: str | None
    # 询问变量列表,默认为空列表
+ ask_variables: list[dict[str, Any]] = Field(default_factory=list)

    # Pydantic配置:允许根据对象属性创建模型(ORM模式)
+ model_config = {"from_attributes": True}  

18.6 测试 #

curl --location --request POST "http://127.0.0.1:8000/api/agents" ^
--header "Content-Type: application/json" ^
--data-raw "{    \"avatar\": \"/uploads/b20afbe9728742579b53d003ab7a7008.png\",    \"name\": \"旅游规划智能体\",    \"description\": \"面向中文出行场景的一站式旅游规划助手:可先问清出发地、目的地、行程与偏好,再结合地点检索、路线规划与天气预报等能力,输出可执行的行程摘要、逐日安排、交通与住宿建议及注意事项。\",    \"opening_message\": \"你好,我是你的旅游规划智能体 👋  \\n我可以帮你一站式完成:**去哪玩、怎么去、天气如何、是否适合出行**。\\n\\n为了给你生成一份可执行的旅行方案,我会先快速确认几个关键信息(如出发地、目的地、天数、预算、人数和偏好),然后再结合工具结果给你:\\n1. 结论摘要  \\n2. 逐日行程  \\n3. 路线与交通建议  \\n4. 天气与出行提醒  \\n5. 预算与注意事项\\n\\n我们现在开始吧,我先问你第一个问题。\",    \"system_prompt\": \"你是“旅游规划智能体”,目标是帮助用户完成从“去哪玩”到“怎么去、天气如何、是否适合出行”的一站式决策。\\n\\n【核心职责】\\n1. 根据用户需求推荐地点(景点/酒店/餐饮等)。\\n2. 提供路线规划(自驾或公共交通),并给出关键出行建议。\\n3. 提供目的地未来天气预报,结合天气给出出行提醒。\\n4. 回答必须真实、可执行、结构清晰,不夸大、不编造。\\n\\n【工具使用规则】\\n- 与地点检索相关:调用 search_place。\\n- 与路线规划相关:调用 plan_route。\\n- 与天气相关:调用 get_travel_forecast。\\n- 只要问题涉及“地点推荐、路线、天气”中的任意一项,应优先调用工具,不要凭空臆测。\\n- 若用户信息不足(如缺少出发地、目的地、天数、偏好),先用 1-2 个关键问题补齐后再调用工具。\\n- 当工具调用失败或结果为空时,说明原因并给出下一步可操作建议(例如改关键词、改地区、改出行方式)。\\n\\n【对话策略】\\n- 用户目标优先:先判断用户是“选目的地”“查路线”“看天气”还是“完整行程规划”。\\n- 组合调用:\\n  - 完整规划时,默认按“地点 -> 路线 -> 天气”顺序调用。\\n  - 若用户已指定地点,可直接“路线 + 天气”。\\n- 对用户提到“高铁/动车/地铁/公交/飞机”等,路线优先使用公共交通思路。\\n- 对用户提到“不走高速”等偏好,路线建议中明确体现。\\n\\n【输出风格】\\n- 默认中文,简洁专业,先结论后细节。\\n- 输出结构建议:\\n  1) 结论摘要(1-3 行)\\n  2) 推荐方案(地点/路线/天气)\\n  3) 注意事项(预算、天气、时段、备选)\\n- 涉及时间、温度、里程、时长时,尽量保留工具结果中的关键数据。\\n- 不输出工具内部实现细节,不暴露密钥、请求头等敏感信息。\\n\\n【安全与边界】\\n- 不编造未获取到的数据;不确定时明确说“不确定”并建议重新检索。\\n- 医疗、法律、政策等高风险问题仅给一般性建议并提示咨询专业机构。\\n- 用户要求与旅游无关的内容时,礼貌回应并引导回到旅游场景。\\n\\n现在开始:优先理解用户旅行意图,并按需调用工具给出可执行建议。\",    \"ask_prompt_template\": \"你是一名资深中文旅行规划师。请基于以下用户信息,输出一份可执行、具体、务实的旅行方案。\\n\\n【用户信息】\\n- 出发地:{{出发地}}\\n- 目的地:{{目的地}}\\n- 游玩天数:{{游玩天数}} 天\\n- 出发日期:{{出发日期}}\\n- 预算档位:{{预算档位}}\\n- 出行人数:{{出行人数}}\\n- 出行偏好:{{出行偏好}}\\n- 交通偏好:{{交通偏好}}\\n- 住宿偏好:{{住宿偏好}}\\n- 必去点:{{必去点}}\\n- 避开项:{{避开项}}\\n\\n【输出要求】\\n1) 先给“总览结论”(3-5条)\\n2) 再给逐日行程(Day1 ~ DayN),每一天包含:\\n   - 上午/下午/晚上安排\\n   - 核心景点与停留时长建议\\n   - 餐饮建议(当地特色)\\n3) 给交通方案对比(至少2种:时间、成本、优缺点)\\n4) 给住宿区域建议(2-3个片区,适合人群、预算)\\n5) 给预算拆分(交通/住宿/餐饮/门票)\\n6) 给注意事项(天气、穿衣、预约、避坑)\\n7) 如果信息不足,先明确列出“仍需补充的信息”,再给“可先执行的临时方案”。\\n\\n请使用清晰的 Markdown 格式输出,标题层级明确,内容尽量具体到可直接执行。\",    \"ask_variables\": [        {            \"key\": \"出发地\",            \"label\": \"出发地\",            \"question\": \"你从哪个城市出发?\",            \"required\": true,            \"default\": \"\"        },        {            \"key\": \"目的地\",            \"label\": \"目的地\",            \"question\": \"你要去哪个城市?\",            \"required\": true,            \"default\": \"\"        },        {            \"key\": \"游玩天数\",            \"label\": \"游玩天数\",            \"question\": \"你计划游玩几天?\",            \"required\": true,            \"default\": \"\"        },        {            \"key\": \"出发日期\",            \"label\": \"出发日期\",            \"question\": \"你的出发日期是?\",            \"required\": true,            \"default\": \"\"        },        {            \"key\": \"预算档位\",            \"label\": \"预算档位\",            \"question\": \"你的预算档位是?(低/中/高)\",            \"required\": true,            \"default\": \"\"        },        {            \"key\": \"出行人数\",            \"label\": \"出行人数\",            \"question\": \"有几人同行?\",            \"required\": true,            \"default\": \"\"        },        {            \"key\": \"出行偏好\",            \"label\": \"出行偏好\",            \"question\": \"你偏好哪种旅行风格?(可选自然风光/人文历史/亲子/美食/轻松等,可多选)\",            \"required\": true,            \"default\": \"\"        },        {            \"key\": \"交通偏好\",            \"label\": \"交通偏好\",            \"question\": \"你偏好哪种交通方式?(高铁/飞机/自驾/无偏好)\",            \"required\": true,            \"default\": \"\"        },        {            \"key\": \"住宿偏好\",            \"label\": \"住宿偏好\",            \"question\": \"你的住宿偏好是?(经济/舒适/高档)\",            \"required\": true,            \"default\": \"\"        },        {            \"key\": \"必去点\",            \"label\": \"必去点\",            \"question\": \"有哪些必去的景点或地点?\",            \"required\": true,            \"default\": \"\"        },        {            \"key\": \"避开项\",            \"label\": \"避开项\",            \"question\": \"有哪些不想去的地方或忌口/限制?\",            \"required\": true,            \"default\": \"\"        }    ],    \"llm_provider_name\": \"深度探索\",    \"llm_model_name\": \"deepseek-chat\",    \"mcp_service_ids\": [        5,        4,        3    ]}"

18.7 旅游规划智能体 #

18.7.1 描述 #

面向中文出行场景的一站式旅游规划助手:可先问清出发地、目的地、行程与偏好,再结合地点检索、路线规划与天气预报等能力,输出可执行的行程摘要、逐日安排、交通与住宿建议及注意事项。

18.7.2 开场白 #

你好,我是你的旅游规划智能体 👋  
我可以帮你一站式完成:**去哪玩、怎么去、天气如何、是否适合出行**。

为了给你生成一份可执行的旅行方案,我会先快速确认几个关键信息(如出发地、目的地、天数、预算、人数和偏好),然后再结合工具结果给你:
1. 结论摘要  
2. 逐日行程  
3. 路线与交通建议  
4. 天气与出行提醒  
5. 预算与注意事项

我们现在开始吧,我先问你第一个问题。

18.7.3 系统提示词 #

你是“旅游规划智能体”,目标是帮助用户完成从“去哪玩”到“怎么去、天气如何、是否适合出行”的一站式决策。

【核心职责】
1. 根据用户需求推荐地点(景点/酒店/餐饮等)。
2. 提供路线规划(自驾或公共交通),并给出关键出行建议。
3. 提供目的地未来天气预报,结合天气给出出行提醒。
4. 回答必须真实、可执行、结构清晰,不夸大、不编造。

【工具使用规则】
- 与地点检索相关:调用 search_place。
- 与路线规划相关:调用 plan_route。
- 与天气相关:调用 get_travel_forecast。
- 只要问题涉及“地点推荐、路线、天气”中的任意一项,应优先调用工具,不要凭空臆测。
- 若用户信息不足(如缺少出发地、目的地、天数、偏好),先用 1-2 个关键问题补齐后再调用工具。
- 当工具调用失败或结果为空时,说明原因并给出下一步可操作建议(例如改关键词、改地区、改出行方式)。

【对话策略】
- 用户目标优先:先判断用户是“选目的地”“查路线”“看天气”还是“完整行程规划”。
- 组合调用:
  - 完整规划时,默认按“地点 -> 路线 -> 天气”顺序调用。
  - 若用户已指定地点,可直接“路线 + 天气”。
- 对用户提到“高铁/动车/地铁/公交/飞机”等,路线优先使用公共交通思路。
- 对用户提到“不走高速”等偏好,路线建议中明确体现。

【输出风格】
- 默认中文,简洁专业,先结论后细节。
- 输出结构建议:
  1) 结论摘要(1-3 行)
  2) 推荐方案(地点/路线/天气)
  3) 注意事项(预算、天气、时段、备选)
- 涉及时间、温度、里程、时长时,尽量保留工具结果中的关键数据。
- 不输出工具内部实现细节,不暴露密钥、请求头等敏感信息。

【安全与边界】
- 不编造未获取到的数据;不确定时明确说“不确定”并建议重新检索。
- 医疗、法律、政策等高风险问题仅给一般性建议并提示咨询专业机构。
- 用户要求与旅游无关的内容时,礼貌回应并引导回到旅游场景。

现在开始:优先理解用户旅行意图,并按需调用工具给出可执行建议。

18.7.4 提问提示词模板 #

你是一名资深中文旅行规划师。请基于以下用户信息,输出一份可执行、具体、务实的旅行方案。

【用户信息】
- 出发地:{{出发地}}
- 目的地:{{目的地}}
- 游玩天数:{{游玩天数}} 天
- 出发日期:{{出发日期}}
- 预算档位:{{预算档位}}
- 出行人数:{{出行人数}}
- 出行偏好:{{出行偏好}}
- 交通偏好:{{交通偏好}}
- 住宿偏好:{{住宿偏好}}
- 必去点:{{必去点}}
- 避开项:{{避开项}}

【输出要求】
1) 先给“总览结论”(3-5条)
2) 再给逐日行程(Day1 ~ DayN),每一天包含:
   - 上午/下午/晚上安排
   - 核心景点与停留时长建议
   - 餐饮建议(当地特色)
3) 给交通方案对比(至少2种:时间、成本、优缺点)
4) 给住宿区域建议(2-3个片区,适合人群、预算)
5) 给预算拆分(交通/住宿/餐饮/门票)
6) 给注意事项(天气、穿衣、预约、避坑)
7) 如果信息不足,先明确列出“仍需补充的信息”,再给“可先执行的临时方案”。

请使用清晰的 Markdown 格式输出,标题层级明确,内容尽量具体到可直接执行。

18.7.5 提问变量配置 #

[
  {"key": "departure_city", "label": "出发地", "default": "", "question": "你从哪个城市出发?", "required": true},
  {"key": "destination_city", "label": "目的地", "default": "", "question": "你要去哪个城市?", "required": true},
  {"key": "days", "label": "游玩天数", "default": "", "question": "你计划游玩几天?", "required": true},
  {"key": "start_date", "label": "出发日期", "default": "", "question": "你的出发日期是?", "required": true},
  {"key": "budget_level", "label": "预算档位", "default": "", "question": "你的预算档位是?(低/中/高)", "required": true},
  {"key": "travelers", "label": "出行人数", "default": "", "question": "有几人同行?", "required": true},
  {"key": "travel_style", "label": "旅行偏好", "default": "", "question": "你偏好哪种旅行风格?(可选自然风光/人文历史/亲子/美食/轻松等,可多选)", "required": true},
  {"key": "transport_preference", "label": "交通偏好", "default": "", "question": "你偏好哪种交通方式?(高铁/飞机/自驾/无偏好)", "required": true},
  {"key": "hotel_preference", "label": "住宿偏好", "default": "", "question": "你的住宿偏好是?(经济/舒适/高档)", "required": true},
  {"key": "must_visit", "label": "必去景点", "default": "", "question": "有哪些必去的景点或地点?", "required": true},
  {"key": "avoid", "label": "避开事项", "default": "", "question": "有哪些不想去的地方或忌口/限制?", "required": true}
]

19. 修改智能体 #

本章节介绍如何通过 API 修改(更新)现有的智能体(Agent)信息。

功能说明

更新智能体接口允许你根据智能体 ID,批量修改其名称、头像、描述、系统提示词、大模型配置、MCP 服务列表、提问模板和变量等属性。未提交的字段保持原值,提交为 null(None)的字段将被设为 null。接口会验证相关引用数据的有效性(如大模型提供商、模型、MCP 服务是否存在与匹配)。

请求路径

PUT /api/agents/{agent_id}
  • agent_id:要修改的智能体的主键 ID。

请求参数

请求体需为 JSON,字段参考 AgentUpdate 模型,常用字段包括:

字段名 类型 是否可选 含义
avatar str 是 智能体头像(URL)
name str 是 智能体名称
description str 是 智能体描述
opening_message str 是 智能体开场白
system_prompt str 是 智能体系统提示词
llm_provider_name str 是 大语言模型提供商(如"深度探索")
llm_model_name str 是 LLM模型名称(如"deepseek-chat")
mcp_service_ids list[int] 是 关联的MCP服务ID列表
ask_prompt_template str 是 提问题模板
ask_variables list[dict[str, Any]] 是 提问变量配置(每项结构见下方)

注意:

  • 所有字段均为可选,未包含的字段自动保持原值。
  • 如要将某字段“清空”,可设为 null 或类型的空值。
  • 只有传递的值才会被更新。

提问变量配置说明

ask_variables 字段为一个列表,每项为一个字典,包含如下常用键:

  • key: 变量字段名(建议英文/拼音)
  • label: 变量中文名
  • question: 用户交互时的问题
  • required: 是否必填
  • default: 默认值

例如:

[
  { "key": "departure_city", "label": "出发地", "question": "你从哪个城市出发?", "required": true, "default": "" },
  { "key": "destination_city", "label": "目的地", "question": "你要去哪个城市?", "required": true, "default": "" }
]

示例

下面是一个完整的 PUT 修改请求示例:

curl --location --request PUT "http://127.0.0.1:8000/api/agents/1" ^
--header "Content-Type: application/json" ^
--data-raw '{
  "avatar": "/uploads/your_avatar.png",
  "name": "智能体新名称",
  "description": "更新后的描述内容",
  "opening_message": "新开场白",
  "system_prompt": "你是新的智能体系统提示词",
  "llm_provider_name": "深度探索",
  "llm_model_name": "deepseek-chat",
  "mcp_service_ids": [1,2,3],
  "ask_prompt_template": "请基于以下信息输出……",
  "ask_variables": [
    { "key": "a", "label": "A", "question": "问题A?", "required": true, "default": "" },
    { "key": "b", "label": "B", "question": "问题B?", "required": false, "default": "B1" }
  ]
}'

响应结果

成功时,返回更新后的 Agent 详细信息(结构同创建)。

出现错误时返回标准的 API 错误对象,比如:

  • 404:指定 ID 的 Agent 不存在
  • 400:大模型提供商、模型、或 MCP 服务引用有误

注意事项

  • 更新时会做基础校验和引用合法性校验。
  • 提交的 mcp_service_ids 若非整数列表或含无效 ID,会导致 400 错误。
  • 字符串字段会自动去除首尾空格;可为 null 的字段可设为 null。
  • 部分字段如 ask_variables 支持嵌套结构,注意 JSON 格式和内容完整性。

若需仅更新部分字段,仅提交对应字段即可,其他字段将保持当前值不变。

19.1. agent_repository.py #

app/repositories/agent_repository.py

# 导入Session类用于数据库会话管理
from sqlalchemy.orm import Session
from sqlalchemy import select
# 从app模块导入models和schemas
from app import models
from app import schemas

# 定义创建Agent的函数,参数为数据库会话和Agent创建数据,返回创建后的Agent模型
def create_agent(session: Session, data: schemas.AgentCreate) -> models.Agent:
    # 创建Agent模型实例,将前端传入的数据赋值到各个字段
    row = models.Agent(
        avatar=data.avatar,  # 头像
        name=data.name.strip(),  # 名称,去除首尾空格
        description=data.description,  # 描述
        opening_message=(data.opening_message or "").strip() or None,  # 开场白,去除空格,允许为None
        system_prompt=data.system_prompt.strip(),  # 系统提示,去除空格
        llm_provider_name=data.llm_provider_name.strip(),  # LLM提供商名称,去除空格
        llm_model_name=data.llm_model_name.strip(),  # LLM模型名称,去除空格
        mcp_service_ids=data.mcp_service_ids,  # 关联MCP服务ID列表
        ask_prompt_template=(data.ask_prompt_template or "").strip() or None,  # 提问提示词模板,去除空格,允许为None
        ask_variables=data.ask_variables or [],  # 提问变量,默认为空列表
    )
    # 将Agent实例添加到数据库会话
    session.add(row)
    # 提交事务,保存到数据库
    session.commit()
    # 刷新实例,获取数据库生成的最新字段(如自增ID)
    session.refresh(row)
    # 返回创建后的Agent对象
    return row

# 定义list_agents函数,接收一个数据库会话db作为参数,返回Agent对象列表
def list_agents(session: Session) -> list[models.Agent]:
    # 使用select语句查询Agent表,并按id倒序排序,获取所有Agent对象
    return list(session.scalars(select(models.Agent).order_by(models.Agent.id.desc())).all())    

# 定义一个用于更新智能体(Agent)的函数
+def update_agent(session: Session, row: models.Agent, data: schemas.AgentUpdate) -> models.Agent:
    # 如果数据中的avatar不为None,则更新头像字段
+ if data.avatar is not None:
+     row.avatar = data.avatar
    # 如果数据中的name不为None,则去除空格后更新名称字段
+ if data.name is not None:
+     row.name = data.name.strip()
    # 如果数据中的description不为None,则更新描述信息
+ if data.description is not None:
+     row.description = data.description
    # 如果数据中的opening_message不为None,则去除空格后更新开场白,允许为None
+ if data.opening_message is not None:
+     row.opening_message = (data.opening_message or "").strip() or None
    # 如果数据中的system_prompt不为None,则去除空格后更新系统提示词
+ if data.system_prompt is not None:
+     row.system_prompt = data.system_prompt.strip()
    # 如果数据中的llm_provider_name不为None,则去除空格后更新LLM提供商名称
+ if data.llm_provider_name is not None:
+     row.llm_provider_name = data.llm_provider_name.strip()
    # 如果数据中的llm_model_name不为None,则去除空格后更新LLM模型名称
+ if data.llm_model_name is not None:
+     row.llm_model_name = data.llm_model_name.strip()
    # 如果数据中的mcp_service_ids不为None,则更新关联的MCP服务ID列表
+ if data.mcp_service_ids is not None:
+     row.mcp_service_ids = data.mcp_service_ids
    # 如果数据中的ask_prompt_template不为None,则去除空格后更新提问提示词模板,允许为None
+ if data.ask_prompt_template is not None:
+     row.ask_prompt_template = (data.ask_prompt_template or "").strip() or None
    # 如果数据中的ask_variables不为None,则更新提问变量配置
+ if data.ask_variables is not None:
+     row.ask_variables = data.ask_variables
    # 提交事务保存更改
+ session.commit()
    # 刷新实例获取数据库中的最新数据
+ session.refresh(row)
    # 返回更新后的Agent对象
+ return row

# 定义get_agent函数,根据给定的agent_id,从数据库中获取对应的Agent对象
# 参数db为数据库会话,agent_id为智能体主键ID
# 若找到对应的Agent,返回Agent对象,否则返回None
+def get_agent(session: Session, agent_id: int) -> models.Agent | None:
+ return session.get(models.Agent, agent_id)      

19.2. agents.py #

app/routers/agents.py

# 从 fastapi 导入 APIRouter, Depends 以及 HTTPException 异常
from fastapi import APIRouter, Depends, HTTPException
# 从 sqlalchemy.orm 导入 Session 会话对象
from sqlalchemy.orm import Session

# 从 app 包分别导入 schemas 模块
from app import schemas
# 导入数据库依赖获取函数
from app.database import get_session
# 导入 agent_repository、llm_repository 和 mcp_repository,分别处理不同的数据操作
from app.repositories import agent_repository, llm_repository, mcp_repository

# 创建一个 APIRouter 实例,设置路由的前缀和标签
router = APIRouter(prefix="/api/agents", tags=["agents"])

# 校验相关引用有效性(大模型提供商、模型、MCP 服务)
def _validate_refs(session: Session, provider_name: str, model_name: str, mcp_service_ids: list[int]) -> None:
    # 根据提供商名称查询 LLM 提供商数据
    llm_row = llm_repository.get_llm_by_provider_name(session, provider_name.strip())
    # 如果没有找到对应的 LLM 提供商则抛出 HTTP 400 异常
    if not llm_row:
        raise HTTPException(status_code=400, detail=f"大语言模型提供商不存在: {provider_name}")
    # 获取该提供商下的所有模型名,并做字符串清洗
    llm_models = [str(m or "").strip() for m in (llm_row.model_names or [])]
    # 如果传入的模型名称不属于当前提供商的模型,则抛出 HTTP 400 异常
    if model_name.strip() not in llm_models:
        raise HTTPException(status_code=400, detail=f"模型名称不属于提供商 {provider_name}: {model_name}")
    # 遍历所有 mcp_service_ids
    for sid in mcp_service_ids:
        # 校验每一个 mcp_service 是否存在,如果不存在则抛出 400 异常
        if not mcp_repository.get_mcp_service(session, int(sid)):
            raise HTTPException(status_code=400, detail=f"MCP 服务不存在: {sid}")

# 定义创建 agent 的接口,POST 请求,响应体为 AgentOut 模型
@router.post("", response_model=schemas.AgentOut)
def create_agent(payload: schemas.AgentCreate, session: Session = Depends(get_session)):
    # 调用 _validate_refs 校验 Agent 创建时关联的 LLM 提供商、模型、MCP 服务是否有效
    _validate_refs(session, payload.llm_provider_name, payload.llm_model_name, payload.mcp_service_ids)
    # 校验通过后,调用 agent_repository 创建新的 Agent,并返回创建结果
    return agent_repository.create_agent(session, payload)

# 定义 GET 接口用于获取所有 Agent 列表,响应为 AgentOut 对象列表
@router.get("", response_model=list[schemas.AgentOut])
# 定义 list_agents 视图函数,依赖注入数据库会话 session
def list_agents(session: Session = Depends(get_session)):
    # 调用 agent_repository 的 list_agents 方法,获取所有 Agent 数据
    return agent_repository.list_agents(session)    

# 定义更新指定 agent_id 智能体信息的接口,返回更新后的 AgentOut 响应模型
+@router.put("/{agent_id}", response_model=schemas.AgentOut)
# update_agent 视图函数,接收 agent_id、更新数据 payload,和数据库会话 session
+def update_agent(agent_id: int, payload: schemas.AgentUpdate, session: Session = Depends(get_session)):
    # 根据 agent_id 从数据库查询原有的 agent 记录
+ row = agent_repository.get_agent(session, agent_id)
    # 如果未查到该记录,则抛出 404 错误,提示“记录不存在”
+ if not row:
+     raise HTTPException(status_code=404, detail="记录不存在")

    # 优先使用提交的数据,否则使用原有值,确定最终的 provider 名称
+ provider_name = payload.llm_provider_name if payload.llm_provider_name is not None else row.llm_provider_name
    # 优先使用提交的数据,否则使用原有值,确定最终的 model 名称
+ model_name = payload.llm_model_name if payload.llm_model_name is not None else row.llm_model_name
    # 优先使用提交的数据,否则使用原有值,确定 mcp_service_ids
+ mcp_service_ids = payload.mcp_service_ids if payload.mcp_service_ids is not None else row.mcp_service_ids
    # 校验 provider/model/mcp_service 引用是否合法
+ _validate_refs(session, provider_name, model_name, mcp_service_ids)

    # 调用仓库方法更新 agent 记录并返回更新结果
+ return agent_repository.update_agent(session, row, payload)        

19.3. schemas.py #

app/schemas.py

# 导入枚举类型Enum
from enum import Enum

# 导入Any类型用于类型注解
from typing import Any

# 从pydantic导入基模型BaseModel、字段类型Field、字段校验器field_validator
from pydantic import BaseModel, Field, field_validator


# 定义MCP协议枚举类型
class McpProtocol(str, Enum):
    # 定义stdio协议
    stdio = "stdio"
    # 定义streamable-http协议
    streamable_http = "streamable-http"
    # 定义sse协议
    sse = "sse"


# 定义MCP服务基础模型
class McpServiceBase(BaseModel):
    # 服务名称,字符串类型,必填,长度1~255
    name: str = Field(..., min_length=1, max_length=255)
    # 服务描述,字符串类型,可为空
    description: str | None = None
    # 协议字段,采用McpProtocol枚举
    protocol: McpProtocol
    # 配置信息,类型为字符串键到任意类型的字典
    config: dict[str, Any]

    # 对config字段添加验证器,保证其为字典类型
    @field_validator("config", mode="before")
    @classmethod
    def config_not_empty(cls, v: Any) -> Any:
        # 如果config不是dict类型,则抛出异常
        if not isinstance(v, dict):
            raise ValueError("config必须是JSON对象")
        # 返回config
        return v


# 定义MCP服务创建模型,继承McpServiceBase
class McpServiceCreate(McpServiceBase):
    pass


# 定义MCP服务更新模型
class McpServiceUpdate(BaseModel):
    # 名称,可选,长度1~255
    name: str | None = Field(None, min_length=1, max_length=255)
    # 描述,可选
    description: str | None = None
    # 协议,可选
    protocol: McpProtocol | None = None
    # 配置信息,可选
    config: dict[str, Any] | None = None


# 定义MCP服务输出模型类
class McpServiceOut(BaseModel):
    # MCP服务器的ID
    id: int
    # MCP服务器的名称
    name: str
    # MCP服务器描述
    description: str | None
    # MCP服务器的协议 strreamable-http sse stdio
    protocol: str
    # MCP服务器的配置信息,是字典类型
    config: dict[str, Any]
    # 设置模型配置,允许从ORM对象属性直接读取数据
    model_config = {"from_attributes": True}


# 定义MCP测试请求模型
class McpTestRequest(BaseModel):
    # 协议类型
    protocol: McpProtocol
    # 配置信息,必为dict
    config: dict[str, Any]

    # 对config字段进行验证,必须为字典
    @field_validator("config", mode="before")
    @classmethod
    def config_is_object(cls, v: Any):
        # 如果不是dict类型,抛出异常
        if not isinstance(v, dict):
            raise ValueError("config字段的值必须是JSON对象")
        # 返回config
        return v


# 定义MCP测试响应类型
class McpTestResult(BaseModel):
    # 是否成功
    ok: bool
    # 消息内容
    message: str
    # 工具列表,默认为空列表
    tools: list[dict[str, Any]] = Field(default_factory=list)


class LlmModelBase(BaseModel):
    provider_name: str = Field(..., min_length=1, max_length=255)
    provider_icon: str | None = None
    api_base_url: str = Field(..., min_length=1, max_length=1024)
    api_key: str = Field(..., min_length=1, max_length=1024)
    api_key_url: str | None = Field(None, max_length=1024)
    model_names: list[str] = Field(default_factory=list)

    # 用来对模型名称进行校验和归一化处理 要求模型名全是小写,不能为空,不能重复
    @field_validator("model_names")
    @classmethod
    def normalize_model_names(cls, v: list[str]) -> list[str]:
        out: list[str] = []
        # 用来对模型名进行去重
        seen: set[str] = set()
        for item in v or []:
            name = str(item or "").strip()
            # 过滤空的模型名
            if not name:
                continue
            # 全是小写
            key = name.lower()
            if key in seen:
                continue
            seen.add(key)
            out.append(name)
        return out


class LlmModelCreate(LlmModelBase):
    pass


class LlmModelUpdate(LlmModelBase):
    pass


class LlmModelOut(BaseModel):
    id: int
    provider_name: str
    provider_icon: str | None
    api_base_url: str
    api_key: str
    api_key_url: str
    model_names: list[str]
    # Pydantic的配置,启用从属性赋值(用于ORM模型) 可以实现从ORM的实例直接默认转成Pydanic类的实例
    model_config = {"from_attributes": True}


class UploadImageResult(BaseModel):
    url: str


class LlmModelTestRequest(BaseModel):
    api_base_url: str = Field(..., min_length=1, max_length=1024)
    api_key: str = Field(..., min_length=1, max_length=1024)


class LlmModelTestResult(BaseModel):
    ok: bool
    messsage: str
    # 检测到的模型名称列表,默认为空列表
    models: list[str] = Field(default_factory=list)


# 定义函数,用于规范化 ask_variables 字段,输入为可选的字典列表,返回规范化后的字典列表
def normalize_ask_variables(v):
    # 用于保存处理后的变量字典
  out = []
    # 用于记录已出现过的变量key,实现去重
  seen = set()
    # 遍历输入列表(如果为None则转为空列表)
  for item in v or []:
        # 如果当前元素不是字典类型,则跳过
      if not isinstance(item, dict):
          continue
        # 从字典中获取key字段,去除首尾空白转字符串
      key = str(item.get("key") or "").strip()
        # 如果key为空,则跳过本轮
      if not key:
          continue
        # 如果key已出现,则跳过实现去重
      if key in seen:
          continue
        # 将当前key加入去重集合
      seen.add(key)
        # 获取question字段、去空白
      question = str(item.get("question") or "").strip()
        # 获取label字段、去空白
      label = str(item.get("label") or "").strip()
        # 获取default字段、去空白
      default_value = str(item.get("default") or "").strip()
        # 获取required字段,默认为True
      required = bool(item.get("required", True))
        # 将变量信息归一化后放入输出列表
      out.append(
          {
              "key": key,
              "label": label,
              "question": question or f"请提供 {label or key}",
              "required": required,
              "default": default_value,
          }
      )
    # 返回归一化和去重后的变量列表
  return out


# 定义 AgentBase 基础模型,继承自 Pydantic 的 BaseModel
class AgentBase(BaseModel):
    # 头像字段,可为空
  avatar: str | None = None
    # 智能体名称,必填,长度1~255
  name: str = Field(..., min_length=1, max_length=255)
    # 描述信息,可为空
  description: str | None = None
    # 开场白内容,可为空
  opening_message: str | None = None
    # 智能体系统提示,必填,最小长度1
  system_prompt: str = Field(..., min_length=1)
    # LLM 提供商名称,必填,长度1~255
  llm_provider_name: str = Field(..., min_length=1, max_length=255)
    # LLM 模型名称,必填,长度1~255
  llm_model_name: str = Field(..., min_length=1, max_length=255)
    # 关联的 MCP 服务ID列表,默认为空列表
  mcp_service_ids: list[int] = Field(default_factory=list)
    # 询问提示词模板,可为空
  ask_prompt_template: str | None = None
    # 询问变量列表,默认为空列表
  ask_variables: list[dict[str, Any]] = Field(default_factory=list)

    # 对 mcp_service_ids 字段做归一化校验
  @field_validator("mcp_service_ids")
  @classmethod
  def normalize_mcp_service_ids(cls, v):
        # 存储去重后的有效 id
      out = []
        # 已经出现过的 id 集合
      seen = set()
        # 遍历 id 列表(防止为 None)
      for item in v or []:
            # 转成整数类型
          num = int(item)
            # 跳过小于等于0的无效 id
          if num <= 0:
              continue
            # 跳过重复 id
          if num in seen:
              continue
            # 添加到去重集合
          seen.add(num)
            # 添加到输出集合
          out.append(num)
        # 返回整理后的 id 列表
      return out

    # 对 ask_variables 字段做归一化校验
  @field_validator("ask_variables")
  @classmethod
  def normalize_ask_variables(cls, v):
        # 使用 _normalize_ask_variables 函数处理
      return normalize_ask_variables(v)

# 定义 AgentCreate 创建模型,继承自 AgentBase,无额外字段
class AgentCreate(AgentBase):
  pass        

# 定义AgentOut响应模型,继承自BaseModel
class AgentOut(BaseModel):
    # 主键ID
  id: int
    # 头像,允许为None
  avatar: str | None
    # 智能体名称
  name: str
    # 智能体描述,允许为None
  description: str | None
    # 开场白,允许为None
  opening_message: str | None
    # 智能体系统提示,不可为None
  system_prompt: str
    # LLM提供商名称
  llm_provider_name: str
    # LLM模型名称
  llm_model_name: str
    # 关联的MCP服务ID列表
  mcp_service_ids: list[int]
    # 询问提示词模板,允许为None
  ask_prompt_template: str | None
    # 询问变量列表,默认为空列表
  ask_variables: list[dict[str, Any]] = Field(default_factory=list)

    # Pydantic配置:允许根据对象属性创建模型(ORM模式)
  model_config = {"from_attributes": True}  


# 定义AgentUpdate模型,用于部分更新Agent,继承自Pydantic的BaseModel
+class AgentUpdate(BaseModel):
    # 头像字段,允许为None
+ avatar: str | None = None
    # 名称字段,允许为None,若不为None则要求长度1~255
+ name: str | None = Field(None, min_length=1, max_length=255)
    # 描述字段,允许为None
+ description: str | None = None
    # 开场白字段,允许为None
+ opening_message: str | None = None
    # 系统提示词字段,允许为None,若不为None则要求最小长度1
+ system_prompt: str | None = Field(None, min_length=1)
    # LLM提供商名称,允许为None,若不为None长度1~255
+ llm_provider_name: str | None = Field(None, min_length=1, max_length=255)
    # LLM模型名称,允许为None,若不为None长度1~255
+ llm_model_name: str | None = Field(None, min_length=1, max_length=255)
    # 关联的MCP服务ID列表,允许为None
+ mcp_service_ids: list[int] | None = None
    # 询问提示词模板,允许为None
+ ask_prompt_template: str | None = None
    # 询问变量列表,允许为None
+ ask_variables: list[dict[str, Any]] | None = None

    # 对mcp_service_ids字段进行校验和去重,允许为None
+ @field_validator("mcp_service_ids")
+ @classmethod
+ def normalize_mcp_service_ids_optional(cls, v: list[int] | None) -> list[int] | None:
        # 如果为None,直接返回None
+     if v is None:
+         return None
        # 定义用于存放合法、去重后的ID的列表
+     out: list[int] = []
        # 定义用于去重的集合
+     seen: set[int] = set()
        # 遍历传入的每个ID
+     for item in v:
            # 转为整数
+         num = int(item)
            # 跳过小于等于0的无效ID
+         if num <= 0:
+             continue
            # 跳过重复ID
+         if num in seen:
+             continue
            # 加入去重集合
+         seen.add(num)
            # 加入输出列表
+         out.append(num)
        # 返回整理后的ID列表
+     return out

    # 对ask_variables字段进行归一化与合法性检查,允许为None
+ @field_validator("ask_variables")
+ @classmethod
+ def normalize_ask_variables_optional(cls, v: list[dict[str, Any]] | None) -> list[dict[str, Any]] | None:
        # 如果为None直接返回None
+     if v is None:
+         return None
        # 使用normalize_ask_variables工具函数归一化处理
+     return normalize_ask_variables(v)    

19.4 测试 #

curl --location --request PUT "http://127.0.0.1:8000/api/agents/1" ^
--header "Content-Type: application/json" ^
--data-raw "{    \"avatar\": \"/uploads/b20afbe9728742579b53d003ab7a7008.png\",    \"name\": \"旅游规划智能体2\",    \"description\": \"面向中文出行场景的一站式旅游规划助手:可先问清出发地、目的地、行程与偏好,再结合地点检索、路线规划与天气预报等能力,输出可执行的行程摘要、逐日安排、交通与住宿建议及注意事项。\",    \"opening_message\": \"你好,我是你的旅游规划智能体 👋  \\n我可以帮你一站式完成:**去哪玩、怎么去、天气如何、是否适合出行**。\\n\\n为了给你生成一份可执行的旅行方案,我会先快速确认几个关键信息(如出发地、目的地、天数、预算、人数和偏好),然后再结合工具结果给你:\\n1. 结论摘要  \\n2. 逐日行程  \\n3. 路线与交通建议  \\n4. 天气与出行提醒  \\n5. 预算与注意事项\\n\\n我们现在开始吧,我先问你第一个问题。\",    \"system_prompt\": \"你是“旅游规划智能体”,目标是帮助用户完成从“去哪玩”到“怎么去、天气如何、是否适合出行”的一站式决策。\\n\\n【核心职责】\\n1. 根据用户需求推荐地点(景点/酒店/餐饮等)。\\n2. 提供路线规划(自驾或公共交通),并给出关键出行建议。\\n3. 提供目的地未来天气预报,结合天气给出出行提醒。\\n4. 回答必须真实、可执行、结构清晰,不夸大、不编造。\\n\\n【工具使用规则】\\n- 与地点检索相关:调用 search_place。\\n- 与路线规划相关:调用 plan_route。\\n- 与天气相关:调用 get_travel_forecast。\\n- 只要问题涉及“地点推荐、路线、天气”中的任意一项,应优先调用工具,不要凭空臆测。\\n- 若用户信息不足(如缺少出发地、目的地、天数、偏好),先用 1-2 个关键问题补齐后再调用工具。\\n- 当工具调用失败或结果为空时,说明原因并给出下一步可操作建议(例如改关键词、改地区、改出行方式)。\\n\\n【对话策略】\\n- 用户目标优先:先判断用户是“选目的地”“查路线”“看天气”还是“完整行程规划”。\\n- 组合调用:\\n  - 完整规划时,默认按“地点 -> 路线 -> 天气”顺序调用。\\n  - 若用户已指定地点,可直接“路线 + 天气”。\\n- 对用户提到“高铁/动车/地铁/公交/飞机”等,路线优先使用公共交通思路。\\n- 对用户提到“不走高速”等偏好,路线建议中明确体现。\\n\\n【输出风格】\\n- 默认中文,简洁专业,先结论后细节。\\n- 输出结构建议:\\n  1) 结论摘要(1-3 行)\\n  2) 推荐方案(地点/路线/天气)\\n  3) 注意事项(预算、天气、时段、备选)\\n- 涉及时间、温度、里程、时长时,尽量保留工具结果中的关键数据。\\n- 不输出工具内部实现细节,不暴露密钥、请求头等敏感信息。\\n\\n【安全与边界】\\n- 不编造未获取到的数据;不确定时明确说“不确定”并建议重新检索。\\n- 医疗、法律、政策等高风险问题仅给一般性建议并提示咨询专业机构。\\n- 用户要求与旅游无关的内容时,礼貌回应并引导回到旅游场景。\\n\\n现在开始:优先理解用户旅行意图,并按需调用工具给出可执行建议。\",    \"ask_prompt_template\": \"你是一名资深中文旅行规划师。请基于以下用户信息,输出一份可执行、具体、务实的旅行方案。\\n\\n【用户信息】\\n- 出发地:{{出发地}}\\n- 目的地:{{目的地}}\\n- 游玩天数:{{游玩天数}} 天\\n- 出发日期:{{出发日期}}\\n- 预算档位:{{预算档位}}\\n- 出行人数:{{出行人数}}\\n- 出行偏好:{{出行偏好}}\\n- 交通偏好:{{交通偏好}}\\n- 住宿偏好:{{住宿偏好}}\\n- 必去点:{{必去点}}\\n- 避开项:{{避开项}}\\n\\n【输出要求】\\n1) 先给“总览结论”(3-5条)\\n2) 再给逐日行程(Day1 ~ DayN),每一天包含:\\n   - 上午/下午/晚上安排\\n   - 核心景点与停留时长建议\\n   - 餐饮建议(当地特色)\\n3) 给交通方案对比(至少2种:时间、成本、优缺点)\\n4) 给住宿区域建议(2-3个片区,适合人群、预算)\\n5) 给预算拆分(交通/住宿/餐饮/门票)\\n6) 给注意事项(天气、穿衣、预约、避坑)\\n7) 如果信息不足,先明确列出“仍需补充的信息”,再给“可先执行的临时方案”。\\n\\n请使用清晰的 Markdown 格式输出,标题层级明确,内容尽量具体到可直接执行。\",    \"ask_variables\": [        {            \"key\": \"出发地\",            \"label\": \"出发地\",            \"question\": \"你从哪个城市出发?\",            \"required\": true,            \"default\": \"\"        },        {            \"key\": \"目的地\",            \"label\": \"目的地\",            \"question\": \"你要去哪个城市?\",            \"required\": true,            \"default\": \"\"        },        {            \"key\": \"游玩天数\",            \"label\": \"游玩天数\",            \"question\": \"你计划游玩几天?\",            \"required\": true,            \"default\": \"\"        },        {            \"key\": \"出发日期\",            \"label\": \"出发日期\",            \"question\": \"你的出发日期是?\",            \"required\": true,            \"default\": \"\"        },        {            \"key\": \"预算档位\",            \"label\": \"预算档位\",            \"question\": \"你的预算档位是?(低/中/高)\",            \"required\": true,            \"default\": \"\"        },        {            \"key\": \"出行人数\",            \"label\": \"出行人数\",            \"question\": \"有几人同行?\",            \"required\": true,            \"default\": \"\"        },        {            \"key\": \"出行偏好\",            \"label\": \"出行偏好\",            \"question\": \"你偏好哪种旅行风格?(可选自然风光/人文历史/亲子/美食/轻松等,可多选)\",            \"required\": true,            \"default\": \"\"        },        {            \"key\": \"交通偏好\",            \"label\": \"交通偏好\",            \"question\": \"你偏好哪种交通方式?(高铁/飞机/自驾/无偏好)\",            \"required\": true,            \"default\": \"\"        },        {            \"key\": \"住宿偏好\",            \"label\": \"住宿偏好\",            \"question\": \"你的住宿偏好是?(经济/舒适/高档)\",            \"required\": true,            \"default\": \"\"        },        {            \"key\": \"必去点\",            \"label\": \"必去点\",            \"question\": \"有哪些必去的景点或地点?\",            \"required\": true,            \"default\": \"\"        },        {            \"key\": \"避开项\",            \"label\": \"避开项\",            \"question\": \"有哪些不想去的地方或忌口/限制?\",            \"required\": true,            \"default\": \"\"        }    ],    \"llm_provider_name\": \"深度探索\",    \"llm_model_name\": \"deepseek-chat\",    \"mcp_service_ids\": [        5,        4,        3    ]}"

20. 删除智能体 #

在本节中,我们将实现“删除智能体(Agent)”的接口。该接口允许通过指定 agent_id,从数据库中删除对应的智能体,并返回操作结果。

agent_repository.p

在 app/repositories/agent_repository.py 中,已经增加了删除智能体的核心方法:

def delete_agent(session: Session, row: models.Agent) -> None:
    # 从数据库会话中删除指定的Agent对象
    session.delete(row)
    # 提交事务,保存删除操作
    session.commit()

该方法接收数据库会话和一个已获取的 Agent 实例对象,并将其从数据库中删除。

路由实现

在相应的 FastAPI 路由层(通常在 app/api/routers/agent.py 或类似文件中),提供了删除接口定义:

@router.delete("/{agent_id}")
def delete_agent(agent_id: int, session: Session = Depends(get_session)):
    # 根据 agent_id 查询 agent
    row = agent_repository.get_agent(session, agent_id)
    # 如果查询不到,返回 404
    if not row:
        raise HTTPException(status_code=404, detail="记录不存在")
    # 调用仓库方法执行删除
    agent_repository.delete_agent(session, row)
    # 返回操作成功的响应
    return {"ok": True}

此接口会先验证 agent 记录是否存在,如果不存在,直接返回 404 错误;如果存在,调用仓库方法删除,并返回一个简单的 JSON 成功响应。

测试方式

可以通过 curl 命令行或 Postman 等工具调用该接口进行测试,例如:

curl --location --request DELETE "http://127.0.0.1:8000/api/agents/2" \
--header "Content-Type: application/json"

如果删除成功,将返回:

{"ok": true}

如果指定的 agent_id 不存在,返回如下错误:

{
  "detail": "记录不存在"
}

通过以上步骤,即可实现智能体的删除功能,保持了和其他 Agent 管理接口一致的设计风格和异常处理方式。

20.1. agent_repository.py #

app/repositories/agent_repository.py

# 导入Session类用于数据库会话管理
from sqlalchemy.orm import Session
from sqlalchemy import select
# 从app模块导入models和schemas
from app import models
from app import schemas

# 定义创建Agent的函数,参数为数据库会话和Agent创建数据,返回创建后的Agent模型
def create_agent(session: Session, data: schemas.AgentCreate) -> models.Agent:
    # 创建Agent模型实例,将前端传入的数据赋值到各个字段
    row = models.Agent(
        avatar=data.avatar,  # 头像
        name=data.name.strip(),  # 名称,去除首尾空格
        description=data.description,  # 描述
        opening_message=(data.opening_message or "").strip() or None,  # 开场白,去除空格,允许为None
        system_prompt=data.system_prompt.strip(),  # 系统提示,去除空格
        llm_provider_name=data.llm_provider_name.strip(),  # LLM提供商名称,去除空格
        llm_model_name=data.llm_model_name.strip(),  # LLM模型名称,去除空格
        mcp_service_ids=data.mcp_service_ids,  # 关联MCP服务ID列表
        ask_prompt_template=(data.ask_prompt_template or "").strip() or None,  # 提问提示词模板,去除空格,允许为None
        ask_variables=data.ask_variables or [],  # 提问变量,默认为空列表
    )
    # 将Agent实例添加到数据库会话
    session.add(row)
    # 提交事务,保存到数据库
    session.commit()
    # 刷新实例,获取数据库生成的最新字段(如自增ID)
    session.refresh(row)
    # 返回创建后的Agent对象
    return row

# 定义list_agents函数,接收一个数据库会话db作为参数,返回Agent对象列表
def list_agents(session: Session) -> list[models.Agent]:
    # 使用select语句查询Agent表,并按id倒序排序,获取所有Agent对象
    return list(session.scalars(select(models.Agent).order_by(models.Agent.id.desc())).all())    

# 定义一个用于更新智能体(Agent)的函数
def update_agent(session: Session, row: models.Agent, data: schemas.AgentUpdate) -> models.Agent:
    # 如果数据中的avatar不为None,则更新头像字段
  if data.avatar is not None:
      row.avatar = data.avatar
    # 如果数据中的name不为None,则去除空格后更新名称字段
  if data.name is not None:
      row.name = data.name.strip()
    # 如果数据中的description不为None,则更新描述信息
  if data.description is not None:
      row.description = data.description
    # 如果数据中的opening_message不为None,则去除空格后更新开场白,允许为None
  if data.opening_message is not None:
      row.opening_message = (data.opening_message or "").strip() or None
    # 如果数据中的system_prompt不为None,则去除空格后更新系统提示词
  if data.system_prompt is not None:
      row.system_prompt = data.system_prompt.strip()
    # 如果数据中的llm_provider_name不为None,则去除空格后更新LLM提供商名称
  if data.llm_provider_name is not None:
      row.llm_provider_name = data.llm_provider_name.strip()
    # 如果数据中的llm_model_name不为None,则去除空格后更新LLM模型名称
  if data.llm_model_name is not None:
      row.llm_model_name = data.llm_model_name.strip()
    # 如果数据中的mcp_service_ids不为None,则更新关联的MCP服务ID列表
  if data.mcp_service_ids is not None:
      row.mcp_service_ids = data.mcp_service_ids
    # 如果数据中的ask_prompt_template不为None,则去除空格后更新提问提示词模板,允许为None
  if data.ask_prompt_template is not None:
      row.ask_prompt_template = (data.ask_prompt_template or "").strip() or None
    # 如果数据中的ask_variables不为None,则更新提问变量配置
  if data.ask_variables is not None:
      row.ask_variables = data.ask_variables
    # 提交事务保存更改
  session.commit()
    # 刷新实例获取数据库中的最新数据
  session.refresh(row)
    # 返回更新后的Agent对象
  return row

# 定义get_agent函数,根据给定的agent_id,从数据库中获取对应的Agent对象
# 参数db为数据库会话,agent_id为智能体主键ID
# 若找到对应的Agent,返回Agent对象,否则返回None
def get_agent(session: Session, agent_id: int) -> models.Agent | None:
  return session.get(models.Agent, agent_id)      

# 定义删除智能体(Agent)的方法,接收数据库会话和待删除的Agent对象
+def delete_agent(session: Session, row: models.Agent) -> None:
    # 从数据库会话中删除指定的Agent对象
+ session.delete(row)
    # 提交事务,保存删除操作
+ session.commit()      

20.2. agents.py #

app/routers/agents.py

# 从 fastapi 导入 APIRouter, Depends 以及 HTTPException 异常
from fastapi import APIRouter, Depends, HTTPException
# 从 sqlalchemy.orm 导入 Session 会话对象
from sqlalchemy.orm import Session

# 从 app 包分别导入 schemas 模块
from app import schemas
# 导入数据库依赖获取函数
from app.database import get_session
# 导入 agent_repository、llm_repository 和 mcp_repository,分别处理不同的数据操作
from app.repositories import agent_repository, llm_repository, mcp_repository

# 创建一个 APIRouter 实例,设置路由的前缀和标签
router = APIRouter(prefix="/api/agents", tags=["agents"])

# 校验相关引用有效性(大模型提供商、模型、MCP 服务)
def _validate_refs(session: Session, provider_name: str, model_name: str, mcp_service_ids: list[int]) -> None:
    # 根据提供商名称查询 LLM 提供商数据
    llm_row = llm_repository.get_llm_by_provider_name(session, provider_name.strip())
    # 如果没有找到对应的 LLM 提供商则抛出 HTTP 400 异常
    if not llm_row:
        raise HTTPException(status_code=400, detail=f"大语言模型提供商不存在: {provider_name}")
    # 获取该提供商下的所有模型名,并做字符串清洗
    llm_models = [str(m or "").strip() for m in (llm_row.model_names or [])]
    # 如果传入的模型名称不属于当前提供商的模型,则抛出 HTTP 400 异常
    if model_name.strip() not in llm_models:
        raise HTTPException(status_code=400, detail=f"模型名称不属于提供商 {provider_name}: {model_name}")
    # 遍历所有 mcp_service_ids
    for sid in mcp_service_ids:
        # 校验每一个 mcp_service 是否存在,如果不存在则抛出 400 异常
        if not mcp_repository.get_mcp_service(session, int(sid)):
            raise HTTPException(status_code=400, detail=f"MCP 服务不存在: {sid}")

# 定义创建 agent 的接口,POST 请求,响应体为 AgentOut 模型
@router.post("", response_model=schemas.AgentOut)
def create_agent(payload: schemas.AgentCreate, session: Session = Depends(get_session)):
    # 调用 _validate_refs 校验 Agent 创建时关联的 LLM 提供商、模型、MCP 服务是否有效
    _validate_refs(session, payload.llm_provider_name, payload.llm_model_name, payload.mcp_service_ids)
    # 校验通过后,调用 agent_repository 创建新的 Agent,并返回创建结果
    return agent_repository.create_agent(session, payload)

# 定义 GET 接口用于获取所有 Agent 列表,响应为 AgentOut 对象列表
@router.get("", response_model=list[schemas.AgentOut])
# 定义 list_agents 视图函数,依赖注入数据库会话 session
def list_agents(session: Session = Depends(get_session)):
    # 调用 agent_repository 的 list_agents 方法,获取所有 Agent 数据
    return agent_repository.list_agents(session)    

# 定义更新指定 agent_id 智能体信息的接口,返回更新后的 AgentOut 响应模型
@router.put("/{agent_id}", response_model=schemas.AgentOut)
# update_agent 视图函数,接收 agent_id、更新数据 payload,和数据库会话 session
def update_agent(agent_id: int, payload: schemas.AgentUpdate, session: Session = Depends(get_session)):
    # 根据 agent_id 从数据库查询原有的 agent 记录
  row = agent_repository.get_agent(session, agent_id)
    # 如果未查到该记录,则抛出 404 错误,提示“记录不存在”
  if not row:
      raise HTTPException(status_code=404, detail="记录不存在")

    # 优先使用提交的数据,否则使用原有值,确定最终的 provider 名称
  provider_name = payload.llm_provider_name if payload.llm_provider_name is not None else row.llm_provider_name
    # 优先使用提交的数据,否则使用原有值,确定最终的 model 名称
  model_name = payload.llm_model_name if payload.llm_model_name is not None else row.llm_model_name
    # 优先使用提交的数据,否则使用原有值,确定 mcp_service_ids
  mcp_service_ids = payload.mcp_service_ids if payload.mcp_service_ids is not None else row.mcp_service_ids
    # 校验 provider/model/mcp_service 引用是否合法
  _validate_refs(session, provider_name, model_name, mcp_service_ids)

    # 调用仓库方法更新 agent 记录并返回更新结果
  return agent_repository.update_agent(session, row, payload)        

# 定义一个用于删除指定 agent_id 智能体信息的接口,HTTP 方法为 DELETE
+@router.delete("/{agent_id}")
# delete_agent 视图函数,接收 agent_id 与数据库会话 session
+def delete_agent(agent_id: int, session: Session = Depends(get_session)):
    # 根据 agent_id 从数据库查询对应的 agent 记录
+ row = agent_repository.get_agent(session, agent_id)
    # 如果没有查到对应记录,则抛出 404 异常,并提示“记录不存在”
+ if not row:
+     raise HTTPException(status_code=404, detail="记录不存在")
    # 调用仓库方法删除该 agent 记录
+ agent_repository.delete_agent(session, row)
    # 返回操作成功的响应
+ return {"ok": True}   

20.3 测试 #

curl --location --request DELETE "http://127.0.0.1:8000/api/agents/2" ^
--header "Content-Type: application/json"

21. 创建和删除对话 #

本节介绍“会话管理”模块,即如何对 Agent 智能体的聊天会话(Agent Chat Session)进行增删查等管理操作。

相关概念与数据结构

每个 Agent 智能体可以拥有多个独立的会话(chat session),每个会话具备唯一的会话 ID、所属 Agent ID、会话标题、创建时间、更新时间等属性。具体的数据结构可以参考 Pydantic 输出模型 AgentChatSessionOut:

class AgentChatSessionOut(BaseModel):
    id: int                # 会话ID
    agent_id: int          # 关联的Agent ID
    title: str             # 会话标题
    created_at: Any        # 创建时间
    updated_at: Any        # 最近更新时间

    model_config = {"from_attributes": True}

agent_chat_repository.py

实现了对聊天会话表(Session)的增、删、查操作。例如,创建新对话、查询历史会话列表、删除指定会话等。典型用法如下:

  • list_sessions(session, agent_id)
    获取指定 Agent 下的所有会话列表(按更新时间、ID倒序排列)。

  • create_session(session, agent_id, title=None)
    创建新会话,可指定标题,未给出标题时则使用默认标题“新对话”。

  • get_session(session, session_id)
    通过 session_id 查询会话对象。

  • delete_session(session, row)
    删除指定会话及其所有相关消息。

schemas.py

在 schemas.py 中,定义了专门用于输出单个会话和创建会话的 Pydantic 模型:

  • AgentChatSessionOut:用于接口返回单个会话的详细信息。
  • AgentChatSessionCreate:用于接收新建会话时的参数,如 title。

测试

可以使用 curl 模拟 API 访问,确认会话管理接口的正确性:

curl --location --request POST "http://127.0.0.1:8000/api/agents/1/sessions" \
--header "Content-Type: application/json" \
--data '{"title": "第一次会话"}'

返回结果示例:

{
  "id": 12,
  "agent_id": 1,
  "title": "第一次会话",
  "created_at": "...",
  "updated_at": "..."
}

通过会话管理 API,可以实现智能体下多会话的增删查、维护用户的对话历史记录,支持类似“历史对话”“新建会话”等功能。

21.1. agent_chat_repository.py #

app/repositories/agent_chat_repository.py


# 导入 SQLAlchemy 的 func 和 select 方法用于数据库查询
from sqlalchemy import func, select
# 导入 SQLAlchemy 的 Session 用于数据库会话管理
from sqlalchemy.orm import Session

# 从 app 包导入 models 模块,用于数据库模型操作
from app import models


# 定义一个函数,根据 agent_id 查询对应的所有 AgentChatSession 记录
def list_sessions(session: Session, agent_id: int) -> list[models.AgentChatSession]:
    # 构造一个查询语句,筛选 agent_id 等于传入参数的聊天会话
    stmt = select(models.AgentChatSession).where(models.AgentChatSession.agent_id == agent_id)
    # 按照 updated_at 字段倒序、然后 id 字段倒序排序,确保最新的会话排在前面
    stmt = stmt.order_by(models.AgentChatSession.updated_at.desc(), models.AgentChatSession.id.desc())
    # 执行查询,将所有结果转换为列表并返回
    return list(session.scalars(stmt).all())

# 定义创建聊天会话的方法,传入数据库会话 db、智能体ID agent_id、会话标题 title(可选,默认为 None)
def create_session(session: Session, agent_id: int, title: str | None = None) -> models.AgentChatSession:
    # 创建 AgentChatSession 实例,指定 agent_id 和标题(默认为‘新对话’,去除空格后为空也用‘新对话’)
    row = models.AgentChatSession(
        agent_id=agent_id,
        title=(title or "新对话").strip() or "新对话",
    )
    # 将新建的会话对象添加到数据库 session 中,准备写入数据库
    session.add(row)
    # 提交事务,将新添加的会话保存到数据库
    session.commit()
    # 刷新 row 对象,确保获取数据库自动生成的字段(如主键、时间等)的最新值
    session.refresh(row)
    # 返回新创建的会话对象
    return row 

# 定义一个函数,通过 session_id 获取指定的 AgentChatSession 记录
def get_session(session: Session, session_id: int) -> models.AgentChatSession | None:
    # 调用 session.get 方法,根据主键 session_id 查询 AgentChatSession,如果不存在则返回 None
    return session.get(models.AgentChatSession, session_id)   

# 定义删除指定会话及其所有消息的函数
def delete_session(session: Session, row: models.AgentChatSession) -> None:
    # 删除会话记录本身
    session.delete(row)
    # 提交事务,保存删除操作到数据库
    session.commit()

21.2. agent_chat.py #

app/routers/agent_chat.py

# 从 fastapi 导入 APIRouter、Depends 和 HTTPException,用于路由定义和依赖注入及异常处理
from fastapi import APIRouter, Depends, HTTPException
# 从 sqlalchemy.orm 导入 Session,用于数据库会话管理
from sqlalchemy.orm import Session
import logging
# 从 app.repositories 导入 agent_chat_repository 和 agent_repository,用于数据持久层访问
from app.repositories import agent_chat_repository, agent_repository
# 从 app 导入 schemas,用于数据模型
from app import schemas
# 从 app.database 导入 get_session,用于获取数据库会话
from app.database import get_session

logger = logging.getLogger(__name__)
# 创建 APIRouter 实例,并设置 tags 标签为 "agent-chat"
router = APIRouter(tags=["agent-chat"])


# 声明 GET 接口,路径为 /api/agents/{agent_id}/chat-sessions,返回值为 AgentChatSessionOut 数据模型列表
@router.get("/api/agents/{agent_id}/chat-sessions", response_model=list[schemas.AgentChatSessionOut])
# 定义 list_sessions 视图函数,接收 agent_id 作为路径参数,db 为依赖注入的 Session 对象
def list_sessions(agent_id: int, session: Session = Depends(get_session)):
    # 先检查数据库里是否存在指定 agent,如果不存在则抛出 404 错误
    if not agent_repository.get_agent(session, agent_id):
        raise HTTPException(status_code=404, detail="智能体不存在")
    # 如果智能体存在,查询所有该 agent 下的对话会话,并将结果返回
    return agent_chat_repository.list_sessions(session, agent_id)    


# 声明 POST 路由,用于创建新的智能体聊天会话,响应模型为 AgentChatSessionOut
@router.post("/api/agents/{agent_id}/chat-sessions", response_model=schemas.AgentChatSessionOut)
# 定义 create_session 函数,接收 agent_id、会话创建载荷 payload、依赖注入的数据库会话 session
def create_session(agent_id: int, payload: schemas.AgentChatSessionCreate, session: Session = Depends(get_session)):
    # 先判断数据库中是否存在指定 agent,如果不存在则抛出 404 错误
    if not agent_repository.get_agent(session, agent_id):
        raise HTTPException(status_code=404, detail="智能体不存在")
    # 如果智能体存在,则调用 agent_chat_repository 创建会话,传入数据库会话、智能体ID和会话标题,返回结果
    return agent_chat_repository.create_session(session, agent_id, payload.title)  


# 声明 delete 路由,路径为 /api/agent-chat-sessions/{session_id}
@router.delete("/api/agent-chat-sessions/{session_id}")
# 定义 delete_session 视图函数,接收 session_id 路径参数和数据库会话 db(依赖注入)
def delete_session(session_id: int, session: Session = Depends(get_session)):
    # 根据 session_id 获取会话对象
    row = agent_chat_repository.get_session(session, session_id)
    # 如果会话不存在,抛出 404 异常并提示“会话不存在”
    if not row:
        raise HTTPException(status_code=404, detail="会话不存在")
    # 会话存在,调用仓库方法删除该会话
    agent_chat_repository.delete_session(session, row)
    # 返回删除成功的响应
    return {"ok": True}        

21.3. main.py #

app/main.py

# 导入FastAPI框架
from fastapi import FastAPI

# 导入日志模块
import logging

# 导入异步上下文管理器
from contextlib import asynccontextmanager

# 导入CORS中间件
from fastapi.middleware.cors import CORSMiddleware

# 导入应用配置
from app.config import settings

# 导入数据库模型基类和数据库引擎
from app.database import Base, engine

# 导入所有模型
from app.models import *
+from app.routers import mcp_services, llm_models, uploads,agents,agent_chat

# 导入静态文件中间件
from fastapi.staticfiles import StaticFiles

# 配置日志输出级别为INFO
logging.basicConfig(level=logging.INFO)


# 使用异步上下文管理器定义FastAPI生命周期事件
@asynccontextmanager
async def lifespan(app: FastAPI):
    # 创建所有数据库表
    Base.metadata.create_all(bind=engine)
    # 保持应用运行,等待关闭时执行清理工作
    yield


# 实例化FastAPI应用,指定标题、版本、生命周期管理器
app = FastAPI(title="智能体服务", version="0.1.0", lifespan=lifespan)

# 解析并清洗跨域允许的来源列表
origins = [o.strip() for o in settings.cors_origins if o.strip()]

# 添加跨域中间件,允许指定来源跨域访问
app.add_middleware(
    CORSMiddleware,
    allow_origins=origins,
    allow_credentials=True,
    allow_methods=["*"],
    allow_headers=["*"],
)

# 注册MCP服务相关路由
app.include_router(mcp_services.router)
app.include_router(llm_models.router)
app.include_router(uploads.router)
# 注册对话相关路由
+app.include_router(agent_chat.router)
# 注册智能体相关路由
app.include_router(agents.router)
# 获取上传文件的目录
upload_root = settings.upload_path()
# 保证upload_root目录存在.如果不存在则创建
upload_root.mkdir(parents=True, exist_ok=True)
# 挂载静态文件目录到uploads目录中
app.mount("/uploads", StaticFiles(directory=str(upload_root)), name="uploads")


# 健康检查接口
@app.get("/health")
def health():
    # 返回服务状态ok
    return {"status": "ok"}

21.4. models.py #

app/models.py

# 导入用于处理日期和时间的datetime模块
from datetime import datetime

# 从SQLAlchemy中导入常用的数据类型和函数
from sqlalchemy import DateTime, String, Text, func

# 导入MySQL方言下的JSON字段类型
from sqlalchemy.dialects.mysql import JSON

# 导入ORM映射相关的类型声明和字段映射函数
from sqlalchemy.orm import Mapped, mapped_column

# 从项目数据库模块导入ORM基类
from app.database import Base


# 定义MCP服务的ORM模型
class McpService(Base):
    # 指定数据库表名为"mcp_services"
    __tablename__ = "mcp_services"
    # 定义主键id字段,自增
    id: Mapped[int] = mapped_column(primary_key=True, autoincrement=True)
    # 服务名称,最大长度255,唯一且有索引
    name: Mapped[str] = mapped_column(String(255), unique=True, index=True)
    # 服务描述,可空,使用Text类型
    description: Mapped[str | None] = mapped_column(Text, nullable=True)
    # 协议字段,最大长度32,非空
    protocol: Mapped[str] = mapped_column(String(32), nullable=False)
    # 配置信息,使用MySQL的JSON类型,非空
    config: Mapped[dict] = mapped_column(JSON, nullable=False)


class LlmModel(Base):
    __tablename__ = "llm_models"
    id: Mapped[int] = mapped_column(primary_key=True, autoincrement=True)
    provider_name: Mapped[str] = mapped_column(String(255), unique=True, index=True)
    provider_icon: Mapped[str | None] = mapped_column(Text, nullable=True)
    api_base_url: Mapped[str] = mapped_column(String(1024), nullable=False)
    api_key: Mapped[str] = mapped_column(String(1024), nullable=False)
    api_key_url: Mapped[str | None] = mapped_column(String(1024), nullable=True)
    model_names: Mapped[list[str]] = mapped_column(JSON, nullable=False)


# 定义Agent智能体模型
class Agent(Base):
    # 表名设置
   __tablename__ = "agents"

    # 主键、自增
   id: Mapped[int] = mapped_column(primary_key=True, autoincrement=True)
    # 头像,可空
   avatar: Mapped[str | None] = mapped_column(Text, nullable=True)
    # 名称,有索引
   name: Mapped[str] = mapped_column(String(255), index=True)
    # 描述,可空
   description: Mapped[str | None] = mapped_column(Text, nullable=True)
    # 开场消息,可空
   opening_message: Mapped[str | None] = mapped_column(Text, nullable=True)
    # 系统提示,必填
   system_prompt: Mapped[str] = mapped_column(Text, nullable=False)
    # LLM提供方名称,必填
   llm_provider_name: Mapped[str] = mapped_column(String(255), nullable=False)
    # LLM模型名称,必填
   llm_model_name: Mapped[str] = mapped_column(String(255), nullable=False)
    # MCP服务ID列表,JSON格式,必填
   mcp_service_ids: Mapped[list[int]] = mapped_column(JSON, nullable=False)
    # 询问提示词(模板),可空
   ask_prompt_template: Mapped[str | None] = mapped_column(Text, nullable=True)
    # 询问变量,默认为空列表,JSON格式,不能为空
   ask_variables: Mapped[list[dict]] = mapped_column(JSON, nullable=False, default=list)  

# 定义智能体对话会话模型
+class AgentChatSession(Base):
    # 表名
+  __tablename__ = "agent_chat_sessions"

    # 主键,自增
+  id: Mapped[int] = mapped_column(primary_key=True, autoincrement=True)
    # 智能体ID,有索引且必填
+  agent_id: Mapped[int] = mapped_column(nullable=False, index=True)
    # 会话标题,必填,默认“新对话”
+  title: Mapped[str] = mapped_column(String(255), nullable=False, default="新对话")
    # 创建时间,默认当前时间
+  created_at: Mapped[datetime] = mapped_column(
+      DateTime(timezone=False), server_default=func.now(), nullable=False
+  )
    # 更新时间,默认当前时间,修改时更新
+  updated_at: Mapped[datetime] = mapped_column(
+      DateTime(timezone=False), server_default=func.now(), onupdate=func.now(), nullable=False
+  )       

21.5. agents.py #

app/routers/agents.py

# 从 fastapi 导入 APIRouter, Depends 以及 HTTPException 异常
from fastapi import APIRouter, Depends, HTTPException
# 从 sqlalchemy.orm 导入 Session 会话对象
from sqlalchemy.orm import Session

# 从 app 包分别导入 schemas 模块
from app import schemas
# 导入数据库依赖获取函数
from app.database import get_session
# 导入 agent_repository、llm_repository 和 mcp_repository,分别处理不同的数据操作
from app.repositories import agent_repository, llm_repository, mcp_repository

# 创建一个 APIRouter 实例,设置路由的前缀和标签
router = APIRouter(prefix="/api/agents", tags=["agents"])

# 校验相关引用有效性(大模型提供商、模型、MCP 服务)
def _validate_refs(session: Session, provider_name: str, model_name: str, mcp_service_ids: list[int]) -> None:
    # 根据提供商名称查询 LLM 提供商数据
    llm_row = llm_repository.get_llm_by_provider_name(session, provider_name.strip())
    # 如果没有找到对应的 LLM 提供商则抛出 HTTP 400 异常
    if not llm_row:
        raise HTTPException(status_code=400, detail=f"大语言模型提供商不存在: {provider_name}")
    # 获取该提供商下的所有模型名,并做字符串清洗
    llm_models = [str(m or "").strip() for m in (llm_row.model_names or [])]
    # 如果传入的模型名称不属于当前提供商的模型,则抛出 HTTP 400 异常
    if model_name.strip() not in llm_models:
        raise HTTPException(status_code=400, detail=f"模型名称不属于提供商 {provider_name}: {model_name}")
    # 遍历所有 mcp_service_ids
    for sid in mcp_service_ids:
        # 校验每一个 mcp_service 是否存在,如果不存在则抛出 400 异常
        if not mcp_repository.get_mcp_service(session, int(sid)):
            raise HTTPException(status_code=400, detail=f"MCP 服务不存在: {sid}")

# 定义创建 agent 的接口,POST 请求,响应体为 AgentOut 模型
@router.post("", response_model=schemas.AgentOut)
def create_agent(payload: schemas.AgentCreate, session: Session = Depends(get_session)):
    # 调用 _validate_refs 校验 Agent 创建时关联的 LLM 提供商、模型、MCP 服务是否有效
    _validate_refs(session, payload.llm_provider_name, payload.llm_model_name, payload.mcp_service_ids)
    # 校验通过后,调用 agent_repository 创建新的 Agent,并返回创建结果
    return agent_repository.create_agent(session, payload)

# 定义 GET 接口用于获取所有 Agent 列表,响应为 AgentOut 对象列表
@router.get("", response_model=list[schemas.AgentOut])
# 定义 list_agents 视图函数,依赖注入数据库会话 session
def list_agents(session: Session = Depends(get_session)):
    # 调用 agent_repository 的 list_agents 方法,获取所有 Agent 数据
    return agent_repository.list_agents(session)    

# 定义更新指定 agent_id 智能体信息的接口,返回更新后的 AgentOut 响应模型
@router.put("/{agent_id}", response_model=schemas.AgentOut)
# update_agent 视图函数,接收 agent_id、更新数据 payload,和数据库会话 session
def update_agent(agent_id: int, payload: schemas.AgentUpdate, session: Session = Depends(get_session)):
    # 根据 agent_id 从数据库查询原有的 agent 记录
  row = agent_repository.get_agent(session, agent_id)
    # 如果未查到该记录,则抛出 404 错误,提示“记录不存在”
  if not row:
      raise HTTPException(status_code=404, detail="记录不存在")

    # 优先使用提交的数据,否则使用原有值,确定最终的 provider 名称
  provider_name = payload.llm_provider_name if payload.llm_provider_name is not None else row.llm_provider_name
    # 优先使用提交的数据,否则使用原有值,确定最终的 model 名称
  model_name = payload.llm_model_name if payload.llm_model_name is not None else row.llm_model_name
    # 优先使用提交的数据,否则使用原有值,确定 mcp_service_ids
  mcp_service_ids = payload.mcp_service_ids if payload.mcp_service_ids is not None else row.mcp_service_ids
    # 校验 provider/model/mcp_service 引用是否合法
  _validate_refs(session, provider_name, model_name, mcp_service_ids)

    # 调用仓库方法更新 agent 记录并返回更新结果
  return agent_repository.update_agent(session, row, payload)        

# 定义一个用于删除指定 agent_id 智能体信息的接口,HTTP 方法为 DELETE
@router.delete("/{agent_id}")
# delete_agent 视图函数,接收 agent_id 与数据库会话 session
def delete_agent(agent_id: int, session: Session = Depends(get_session)):
    # 根据 agent_id 从数据库查询对应的 agent 记录
  row = agent_repository.get_agent(session, agent_id)
    # 如果没有查到对应记录,则抛出 404 异常,并提示“记录不存在”
  if not row:
      raise HTTPException(status_code=404, detail="记录不存在")
    # 调用仓库方法删除该 agent 记录
  agent_repository.delete_agent(session, row)
    # 返回操作成功的响应
  return {"ok": True}   

# 定义一个 GET 接口,根据 agent_id 获取指定智能体信息,返回 AgentOut 响应模型
+@router.get("/{agent_id}", response_model=schemas.AgentOut)
# get_agent 视图函数,接收 agent_id 和数据库会话 db 作为参数
+def get_agent(agent_id: int, session: Session = Depends(get_session)):
    # 调用 agent_repository 的 get_agent 方法,根据 agent_id 查询数据库中的智能体记录
+  row = agent_repository.get_agent(session, agent_id)
    # 如果未查到对应记录,则抛出 404 异常,并返回“记录不存在”信息
+  if not row:
+      raise HTTPException(status_code=404, detail="记录不存在")
    # 查询成功则返回该智能体的数据库记录
+  return row       

21.6. schemas.py #

app/schemas.py

# 导入枚举类型Enum
from enum import Enum

# 导入Any类型用于类型注解
from typing import Any

# 从pydantic导入基模型BaseModel、字段类型Field、字段校验器field_validator
from pydantic import BaseModel, Field, field_validator


# 定义MCP协议枚举类型
class McpProtocol(str, Enum):
    # 定义stdio协议
    stdio = "stdio"
    # 定义streamable-http协议
    streamable_http = "streamable-http"
    # 定义sse协议
    sse = "sse"


# 定义MCP服务基础模型
class McpServiceBase(BaseModel):
    # 服务名称,字符串类型,必填,长度1~255
    name: str = Field(..., min_length=1, max_length=255)
    # 服务描述,字符串类型,可为空
    description: str | None = None
    # 协议字段,采用McpProtocol枚举
    protocol: McpProtocol
    # 配置信息,类型为字符串键到任意类型的字典
    config: dict[str, Any]

    # 对config字段添加验证器,保证其为字典类型
    @field_validator("config", mode="before")
    @classmethod
    def config_not_empty(cls, v: Any) -> Any:
        # 如果config不是dict类型,则抛出异常
        if not isinstance(v, dict):
            raise ValueError("config必须是JSON对象")
        # 返回config
        return v


# 定义MCP服务创建模型,继承McpServiceBase
class McpServiceCreate(McpServiceBase):
    pass


# 定义MCP服务更新模型
class McpServiceUpdate(BaseModel):
    # 名称,可选,长度1~255
    name: str | None = Field(None, min_length=1, max_length=255)
    # 描述,可选
    description: str | None = None
    # 协议,可选
    protocol: McpProtocol | None = None
    # 配置信息,可选
    config: dict[str, Any] | None = None


# 定义MCP服务输出模型类
class McpServiceOut(BaseModel):
    # MCP服务器的ID
    id: int
    # MCP服务器的名称
    name: str
    # MCP服务器描述
    description: str | None
    # MCP服务器的协议 strreamable-http sse stdio
    protocol: str
    # MCP服务器的配置信息,是字典类型
    config: dict[str, Any]
    # 设置模型配置,允许从ORM对象属性直接读取数据
    model_config = {"from_attributes": True}


# 定义MCP测试请求模型
class McpTestRequest(BaseModel):
    # 协议类型
    protocol: McpProtocol
    # 配置信息,必为dict
    config: dict[str, Any]

    # 对config字段进行验证,必须为字典
    @field_validator("config", mode="before")
    @classmethod
    def config_is_object(cls, v: Any):
        # 如果不是dict类型,抛出异常
        if not isinstance(v, dict):
            raise ValueError("config字段的值必须是JSON对象")
        # 返回config
        return v


# 定义MCP测试响应类型
class McpTestResult(BaseModel):
    # 是否成功
    ok: bool
    # 消息内容
    message: str
    # 工具列表,默认为空列表
    tools: list[dict[str, Any]] = Field(default_factory=list)


class LlmModelBase(BaseModel):
    provider_name: str = Field(..., min_length=1, max_length=255)
    provider_icon: str | None = None
    api_base_url: str = Field(..., min_length=1, max_length=1024)
    api_key: str = Field(..., min_length=1, max_length=1024)
    api_key_url: str | None = Field(None, max_length=1024)
    model_names: list[str] = Field(default_factory=list)

    # 用来对模型名称进行校验和归一化处理 要求模型名全是小写,不能为空,不能重复
    @field_validator("model_names")
    @classmethod
    def normalize_model_names(cls, v: list[str]) -> list[str]:
        out: list[str] = []
        # 用来对模型名进行去重
        seen: set[str] = set()
        for item in v or []:
            name = str(item or "").strip()
            # 过滤空的模型名
            if not name:
                continue
            # 全是小写
            key = name.lower()
            if key in seen:
                continue
            seen.add(key)
            out.append(name)
        return out


class LlmModelCreate(LlmModelBase):
    pass


class LlmModelUpdate(LlmModelBase):
    pass


class LlmModelOut(BaseModel):
    id: int
    provider_name: str
    provider_icon: str | None
    api_base_url: str
    api_key: str
    api_key_url: str
    model_names: list[str]
    # Pydantic的配置,启用从属性赋值(用于ORM模型) 可以实现从ORM的实例直接默认转成Pydanic类的实例
    model_config = {"from_attributes": True}


class UploadImageResult(BaseModel):
    url: str


class LlmModelTestRequest(BaseModel):
    api_base_url: str = Field(..., min_length=1, max_length=1024)
    api_key: str = Field(..., min_length=1, max_length=1024)


class LlmModelTestResult(BaseModel):
    ok: bool
    messsage: str
    # 检测到的模型名称列表,默认为空列表
    models: list[str] = Field(default_factory=list)


# 定义函数,用于规范化 ask_variables 字段,输入为可选的字典列表,返回规范化后的字典列表
def normalize_ask_variables(v):
    # 用于保存处理后的变量字典
  out = []
    # 用于记录已出现过的变量key,实现去重
  seen = set()
    # 遍历输入列表(如果为None则转为空列表)
  for item in v or []:
        # 如果当前元素不是字典类型,则跳过
      if not isinstance(item, dict):
          continue
        # 从字典中获取key字段,去除首尾空白转字符串
      key = str(item.get("key") or "").strip()
        # 如果key为空,则跳过本轮
      if not key:
          continue
        # 如果key已出现,则跳过实现去重
      if key in seen:
          continue
        # 将当前key加入去重集合
      seen.add(key)
        # 获取question字段、去空白
      question = str(item.get("question") or "").strip()
        # 获取label字段、去空白
      label = str(item.get("label") or "").strip()
        # 获取default字段、去空白
      default_value = str(item.get("default") or "").strip()
        # 获取required字段,默认为True
      required = bool(item.get("required", True))
        # 将变量信息归一化后放入输出列表
      out.append(
          {
              "key": key,
              "label": label,
              "question": question or f"请提供 {label or key}",
              "required": required,
              "default": default_value,
          }
      )
    # 返回归一化和去重后的变量列表
  return out


# 定义 AgentBase 基础模型,继承自 Pydantic 的 BaseModel
class AgentBase(BaseModel):
    # 头像字段,可为空
  avatar: str | None = None
    # 智能体名称,必填,长度1~255
  name: str = Field(..., min_length=1, max_length=255)
    # 描述信息,可为空
  description: str | None = None
    # 开场白内容,可为空
  opening_message: str | None = None
    # 智能体系统提示,必填,最小长度1
  system_prompt: str = Field(..., min_length=1)
    # LLM 提供商名称,必填,长度1~255
  llm_provider_name: str = Field(..., min_length=1, max_length=255)
    # LLM 模型名称,必填,长度1~255
  llm_model_name: str = Field(..., min_length=1, max_length=255)
    # 关联的 MCP 服务ID列表,默认为空列表
  mcp_service_ids: list[int] = Field(default_factory=list)
    # 询问提示词模板,可为空
  ask_prompt_template: str | None = None
    # 询问变量列表,默认为空列表
  ask_variables: list[dict[str, Any]] = Field(default_factory=list)

    # 对 mcp_service_ids 字段做归一化校验
  @field_validator("mcp_service_ids")
  @classmethod
  def normalize_mcp_service_ids(cls, v):
        # 存储去重后的有效 id
      out = []
        # 已经出现过的 id 集合
      seen = set()
        # 遍历 id 列表(防止为 None)
      for item in v or []:
            # 转成整数类型
          num = int(item)
            # 跳过小于等于0的无效 id
          if num <= 0:
              continue
            # 跳过重复 id
          if num in seen:
              continue
            # 添加到去重集合
          seen.add(num)
            # 添加到输出集合
          out.append(num)
        # 返回整理后的 id 列表
      return out

    # 对 ask_variables 字段做归一化校验
  @field_validator("ask_variables")
  @classmethod
  def normalize_ask_variables(cls, v):
        # 使用 _normalize_ask_variables 函数处理
      return normalize_ask_variables(v)

# 定义 AgentCreate 创建模型,继承自 AgentBase,无额外字段
class AgentCreate(AgentBase):
  pass        

# 定义AgentOut响应模型,继承自BaseModel
class AgentOut(BaseModel):
    # 主键ID
  id: int
    # 头像,允许为None
  avatar: str | None
    # 智能体名称
  name: str
    # 智能体描述,允许为None
  description: str | None
    # 开场白,允许为None
  opening_message: str | None
    # 智能体系统提示,不可为None
  system_prompt: str
    # LLM提供商名称
  llm_provider_name: str
    # LLM模型名称
  llm_model_name: str
    # 关联的MCP服务ID列表
  mcp_service_ids: list[int]
    # 询问提示词模板,允许为None
  ask_prompt_template: str | None
    # 询问变量列表,默认为空列表
  ask_variables: list[dict[str, Any]] = Field(default_factory=list)

    # Pydantic配置:允许根据对象属性创建模型(ORM模式)
  model_config = {"from_attributes": True}  


# 定义AgentUpdate模型,用于部分更新Agent,继承自Pydantic的BaseModel
class AgentUpdate(BaseModel):
    # 头像字段,允许为None
  avatar: str | None = None
    # 名称字段,允许为None,若不为None则要求长度1~255
  name: str | None = Field(None, min_length=1, max_length=255)
    # 描述字段,允许为None
  description: str | None = None
    # 开场白字段,允许为None
  opening_message: str | None = None
    # 系统提示词字段,允许为None,若不为None则要求最小长度1
  system_prompt: str | None = Field(None, min_length=1)
    # LLM提供商名称,允许为None,若不为None长度1~255
  llm_provider_name: str | None = Field(None, min_length=1, max_length=255)
    # LLM模型名称,允许为None,若不为None长度1~255
  llm_model_name: str | None = Field(None, min_length=1, max_length=255)
    # 关联的MCP服务ID列表,允许为None
  mcp_service_ids: list[int] | None = None
    # 询问提示词模板,允许为None
  ask_prompt_template: str | None = None
    # 询问变量列表,允许为None
  ask_variables: list[dict[str, Any]] | None = None

    # 对mcp_service_ids字段进行校验和去重,允许为None
  @field_validator("mcp_service_ids")
  @classmethod
  def normalize_mcp_service_ids_optional(cls, v: list[int] | None) -> list[int] | None:
        # 如果为None,直接返回None
      if v is None:
          return None
        # 定义用于存放合法、去重后的ID的列表
      out: list[int] = []
        # 定义用于去重的集合
      seen: set[int] = set()
        # 遍历传入的每个ID
      for item in v:
            # 转为整数
          num = int(item)
            # 跳过小于等于0的无效ID
          if num <= 0:
              continue
            # 跳过重复ID
          if num in seen:
              continue
            # 加入去重集合
          seen.add(num)
            # 加入输出列表
          out.append(num)
        # 返回整理后的ID列表
      return out

    # 对ask_variables字段进行归一化与合法性检查,允许为None
  @field_validator("ask_variables")
  @classmethod
  def normalize_ask_variables_optional(cls, v: list[dict[str, Any]] | None) -> list[dict[str, Any]] | None:
        # 如果为None直接返回None
      if v is None:
          return None
        # 使用normalize_ask_variables工具函数归一化处理
      return normalize_ask_variables(v)    

# 定义用于输出AgentChatSession(智能体聊天会话)的Pydantic模型
+class AgentChatSessionOut(BaseModel):
    # 会话记录的主键ID
+  id: int
    # 关联的Agent智能体ID
+  agent_id: int
    # 会话标题
+  title: str
    # 会话创建时间
+  created_at: Any
    # 会话最近更新时间
+  updated_at: Any

    # 配置项:允许Pydantic模型支持数据库ORM对象的属性映射
+  model_config = {"from_attributes": True}         

# 定义AgentChatSessionCreate用于创建聊天会话的Pydantic模型
+class AgentChatSessionCreate(BaseModel):
    # 可选的会话标题字段,最大长度255个字符,默认为None
+  title: str | None = Field(None, max_length=255)      

22. 获取对话信息列表 #

本接口用于获取指定对话会话(session)的所有消息内容,结果以时间顺序返回。通常用于在前端页面展示整个会话的聊天历史。

路由定义

后端在 app/routers/agent_chat.py 中提供如下接口:

  • 接口地址:GET /api/agent-chat-sessions/{session_id}/messages
  • 请求参数:
    • session_id(路径参数):要获取消息的会话ID。
  • 响应数据:返回该会话的所有消息列表,数据结构为 AgentChatMessageOut 的数组。

示例 FastAPI 路由定义如下:

@router.get("/api/agent-chat-sessions/{session_id}/messages", response_model=list[schemas.AgentChatMessageOut])
def list_messages(session_id: int, session: Session = Depends(get_session)):
    row = agent_chat_repository.get_session(session, session_id)
    if not row:
        raise HTTPException(status_code=404, detail="会话不存在")
    return agent_chat_repository.list_messages(session, session_id)

数据访问层(Repository)

在 app/repositories/agent_chat_repository.py 中,消息列表的查询方法为:

def list_messages(session: Session, session_id: int) -> list[models.AgentChatMessage]:
    stmt = select(models.AgentChatMessage).where(models.AgentChatMessage.session_id == session_id)
    stmt = stmt.order_by(models.AgentChatMessage.id.asc())
    return list(session.scalars(stmt).all())

响应数据结构

每条聊天消息的响应格式来自 app/schemas.py 中的 AgentChatMessageOut 模型:

class AgentChatMessageOut(BaseModel):
    id: int                   # 消息ID
    session_id: int           # 所属会话ID
    role: str                 # 消息角色(user/assistant/system等)
    content: str              # 消息内容
    meta: dict[str, Any] = Field(default_factory=dict)  # 附加元数据
    created_at: Any           # 创建时间

    model_config = {"from_attributes": True}

请求与响应示例

请求

GET /api/agent-chat-sessions/2/messages

响应

[
  {
    "id": 101,
    "session_id": 2,
    "role": "user",
    "content": "你好!",
    "meta": {},
    "created_at": "2024-06-26T13:22:45"
  },
  {
    "id": 102,
    "session_id": 2,
    "role": "assistant",
    "content": "您好,有什么可以帮您?",
    "meta": {},
    "created_at": "2024-06-26T13:22:48"
  }
]

说明

  • 消息已自动按照 id 升序排序,即从最早到最新。
  • 如果指定 session_id 的会话不存在,将返回 404 错误。
  • role 字段可用于区分消息是用户、AI 还是系统发送。
  • meta 字段常用于扩展消息的自定义属性,如流式标记、消息状态等。

通过此接口,前端可完整获取某个智能体会话的全部历史消息,用于展示聊天记录及对话上下文。

22.1. models.py #

app/models.py

# 导入处理日期和时间的datetime模块
from datetime import datetime

# 从SQLAlchemy模块导入常用字段类型和函数
from sqlalchemy import DateTime, String, Text, func

# 导入MySQL方言下的JSON类型
from sqlalchemy.dialects.mysql import JSON

# 导入SQLAlchemy的ORM类型声明和映射列函数
from sqlalchemy.orm import Mapped, mapped_column

# 从项目数据库模块导入ORM的基类
from app.database import Base

# 定义MCP服务的ORM模型
class McpService(Base):
    # 设置数据库表名为"mcp_services"
    __tablename__ = "mcp_services"
    # 定义主键id字段,自动递增
    id: Mapped[int] = mapped_column(primary_key=True, autoincrement=True)
    # 服务名称,字符串类型,最大长度255,唯一且建立索引
    name: Mapped[str] = mapped_column(String(255), unique=True, index=True)
    # 服务描述,文本类型,可为空
    description: Mapped[str | None] = mapped_column(Text, nullable=True)
    # 协议类型,字符串类型,最大长度32,不可为空
    protocol: Mapped[str] = mapped_column(String(32), nullable=False)
    # 配置信息,MySQL JSON类型,不可为空
    config: Mapped[dict] = mapped_column(JSON, nullable=False)

# 定义大语言模型提供方ORM模型
class LlmModel(Base):
    # 设置数据库表名为"llm_models"
    __tablename__ = "llm_models"
    # 主键,自增
    id: Mapped[int] = mapped_column(primary_key=True, autoincrement=True)
    # 提供方名称,字符串类型,唯一且建立索引
    provider_name: Mapped[str] = mapped_column(String(255), unique=True, index=True)
    # 提供方图标,文本类型,可为空
    provider_icon: Mapped[str | None] = mapped_column(Text, nullable=True)
    # API基础URL,字符串类型,不可为空
    api_base_url: Mapped[str] = mapped_column(String(1024), nullable=False)
    # API密钥,字符串类型,不可为空
    api_key: Mapped[str] = mapped_column(String(1024), nullable=False)
    # API密钥获取地址,字符串类型,可为空
    api_key_url: Mapped[str | None] = mapped_column(String(1024), nullable=True)
    # 模型名称列表,JSON类型,不可为空
    model_names: Mapped[list[str]] = mapped_column(JSON, nullable=False)

# 定义Agent智能体ORM模型
class Agent(Base):
    # 设置数据库表名为"agents"
    __tablename__ = "agents"
    # 主键,自增
    id: Mapped[int] = mapped_column(primary_key=True, autoincrement=True)
    # 头像,文本类型,可为空
    avatar: Mapped[str | None] = mapped_column(Text, nullable=True)
    # 名称,字符串类型,建立索引
    name: Mapped[str] = mapped_column(String(255), index=True)
    # 描述,文本类型,可为空
    description: Mapped[str | None] = mapped_column(Text, nullable=True)
    # 开场消息,文本类型,可为空
    opening_message: Mapped[str | None] = mapped_column(Text, nullable=True)
    # 系统提示,文本类型,不可为空
    system_prompt: Mapped[str] = mapped_column(Text, nullable=False)
    # LLM提供方名称,字符串类型,不可为空
    llm_provider_name: Mapped[str] = mapped_column(String(255), nullable=False)
    # LLM模型名称,字符串类型,不可为空
    llm_model_name: Mapped[str] = mapped_column(String(255), nullable=False)
    # MCP服务ID列表,JSON类型,不可为空
    mcp_service_ids: Mapped[list[int]] = mapped_column(JSON, nullable=False)
    # 询问提示模板,文本类型,可为空
    ask_prompt_template: Mapped[str | None] = mapped_column(Text, nullable=True)
    # 询问变量,JSON类型,不可为空,默认空列表
    ask_variables: Mapped[list[dict]] = mapped_column(JSON, nullable=False, default=list)  

# 定义智能体对话会话ORM模型
class AgentChatSession(Base):
    # 设置数据库表名为"agent_chat_sessions"
    __tablename__ = "agent_chat_sessions"
    # 主键,自增
    id: Mapped[int] = mapped_column(primary_key=True, autoincrement=True)
    # 智能体ID,整型,不可为空,建立索引
    agent_id: Mapped[int] = mapped_column(nullable=False, index=True)
    # 会话标题,字符串类型,不可为空,默认值为“新对话”
    title: Mapped[str] = mapped_column(String(255), nullable=False, default="新对话")
    # 创建时间,DateTime类型,默认当前时间,不可为空
    created_at: Mapped[datetime] = mapped_column(
        DateTime(timezone=False), server_default=func.now(), nullable=False
    )
    # 更新时间,DateTime类型,默认当前时间,更新时修改,不可为空
    updated_at: Mapped[datetime] = mapped_column(
        DateTime(timezone=False), server_default=func.now(), onupdate=func.now(), nullable=False
    )

# 定义智能体对话消息模型
+class AgentChatMessage(Base):
    # 表名
+  __tablename__ = "agent_chat_messages"

    # 主键,自增
+  id: Mapped[int] = mapped_column(primary_key=True, autoincrement=True)
    # 会话ID,有索引且必填
+  session_id: Mapped[int] = mapped_column(nullable=False, index=True)
    # 发送者角色(如user/agent),必填
+  role: Mapped[str] = mapped_column(String(32), nullable=False)
    # 消息内容,必填
+  content: Mapped[str] = mapped_column(Text, nullable=False)
    # 附加元信息,默认为空dict,JSON格式
+  meta: Mapped[dict] = mapped_column(JSON, nullable=False, default=dict)
    # 消息创建时间,默认为当前时间
+  created_at: Mapped[datetime] = mapped_column(
+      DateTime(timezone=False), server_default=func.now(), nullable=False
+  )    

22.2. agent_chat_repository.py #

app/repositories/agent_chat_repository.py


# 导入 SQLAlchemy 的 func 和 select 方法用于数据库查询
from sqlalchemy import func, select
# 导入 SQLAlchemy 的 Session 用于数据库会话管理
from sqlalchemy.orm import Session

# 从 app 包导入 models 模块,用于数据库模型操作
from app import models


# 定义一个函数,根据 agent_id 查询对应的所有 AgentChatSession 记录
def list_sessions(session: Session, agent_id: int) -> list[models.AgentChatSession]:
    # 构造一个查询语句,筛选 agent_id 等于传入参数的聊天会话
    stmt = select(models.AgentChatSession).where(models.AgentChatSession.agent_id == agent_id)
    # 按照 updated_at 字段倒序、然后 id 字段倒序排序,确保最新的会话排在前面
    stmt = stmt.order_by(models.AgentChatSession.updated_at.desc(), models.AgentChatSession.id.desc())
    # 执行查询,将所有结果转换为列表并返回
    return list(session.scalars(stmt).all())

# 定义创建聊天会话的方法,传入数据库会话 db、智能体ID agent_id、会话标题 title(可选,默认为 None)
def create_session(session: Session, agent_id: int, title: str | None = None) -> models.AgentChatSession:
    # 创建 AgentChatSession 实例,指定 agent_id 和标题(默认为‘新对话’,去除空格后为空也用‘新对话’)
    row = models.AgentChatSession(
        agent_id=agent_id,
        title=(title or "新对话").strip() or "新对话",
    )
    # 将新建的会话对象添加到数据库 session 中,准备写入数据库
    session.add(row)
    # 提交事务,将新添加的会话保存到数据库
    session.commit()
    # 刷新 row 对象,确保获取数据库自动生成的字段(如主键、时间等)的最新值
    session.refresh(row)
    # 返回新创建的会话对象
    return row 

# 定义一个函数,通过 session_id 获取指定的 AgentChatSession 记录
def get_session(session: Session, session_id: int) -> models.AgentChatSession | None:
    # 调用 session.get 方法,根据主键 session_id 查询 AgentChatSession,如果不存在则返回 None
    return session.get(models.AgentChatSession, session_id)   

# 定义删除指定会话及其所有消息的函数
def delete_session(session: Session, row: models.AgentChatSession) -> None:
    # 删除会话记录本身
    session.delete(row)
    # 提交事务,保存删除操作到数据库
    session.commit()

# 定义一个函数,根据会话ID查询对应的所有聊天消息,并按消息ID升序排序
+def list_messages(session: Session, session_id: int) -> list[models.AgentChatMessage]:
    # 构造查询语句,只筛选session_id为指定值的消息
+  stmt = select(models.AgentChatMessage).where(models.AgentChatMessage.session_id == session_id)
    # 按id字段升序排列消息,确保按先后顺序返回
+  stmt = stmt.order_by(models.AgentChatMessage.id.asc())
    # 执行查询并返回所有结果转换为列表
+  return list(session.scalars(stmt).all())     

22.3. agent_chat.py #

app/routers/agent_chat.py

# 从 fastapi 导入 APIRouter、Depends 和 HTTPException,用于路由定义和依赖注入及异常处理
from fastapi import APIRouter, Depends, HTTPException
# 从 sqlalchemy.orm 导入 Session,用于数据库会话管理
from sqlalchemy.orm import Session
import logging
# 从 app.repositories 导入 agent_chat_repository 和 agent_repository,用于数据持久层访问
from app.repositories import agent_chat_repository, agent_repository
# 从 app 导入 schemas,用于数据模型
from app import schemas
# 从 app.database 导入 get_session,用于获取数据库会话
from app.database import get_session

logger = logging.getLogger(__name__)
# 创建 APIRouter 实例,并设置 tags 标签为 "agent-chat"
router = APIRouter(tags=["agent-chat"])


# 声明 GET 接口,路径为 /api/agents/{agent_id}/chat-sessions,返回值为 AgentChatSessionOut 数据模型列表
@router.get("/api/agents/{agent_id}/chat-sessions", response_model=list[schemas.AgentChatSessionOut])
# 定义 list_sessions 视图函数,接收 agent_id 作为路径参数,db 为依赖注入的 Session 对象
def list_sessions(agent_id: int, session: Session = Depends(get_session)):
    # 先检查数据库里是否存在指定 agent,如果不存在则抛出 404 错误
    if not agent_repository.get_agent(session, agent_id):
        raise HTTPException(status_code=404, detail="智能体不存在")
    # 如果智能体存在,查询所有该 agent 下的对话会话,并将结果返回
    return agent_chat_repository.list_sessions(session, agent_id)    


# 声明 POST 路由,用于创建新的智能体聊天会话,响应模型为 AgentChatSessionOut
@router.post("/api/agents/{agent_id}/chat-sessions", response_model=schemas.AgentChatSessionOut)
# 定义 create_session 函数,接收 agent_id、会话创建载荷 payload、依赖注入的数据库会话 session
def create_session(agent_id: int, payload: schemas.AgentChatSessionCreate, session: Session = Depends(get_session)):
    # 先判断数据库中是否存在指定 agent,如果不存在则抛出 404 错误
    if not agent_repository.get_agent(session, agent_id):
        raise HTTPException(status_code=404, detail="智能体不存在")
    # 如果智能体存在,则调用 agent_chat_repository 创建会话,传入数据库会话、智能体ID和会话标题,返回结果
    return agent_chat_repository.create_session(session, agent_id, payload.title)  


# 声明 delete 路由,路径为 /api/agent-chat-sessions/{session_id}
@router.delete("/api/agent-chat-sessions/{session_id}")
# 定义 delete_session 视图函数,接收 session_id 路径参数和数据库会话 db(依赖注入)
def delete_session(session_id: int, session: Session = Depends(get_session)):
    # 根据 session_id 获取会话对象
    row = agent_chat_repository.get_session(session, session_id)
    # 如果会话不存在,抛出 404 异常并提示“会话不存在”
    if not row:
        raise HTTPException(status_code=404, detail="会话不存在")
    # 会话存在,调用仓库方法删除该会话
    agent_chat_repository.delete_session(session, row)
    # 返回删除成功的响应
    return {"ok": True}        

# 声明一个 GET 路由,根据 session_id 获取会话消息,返回值为 AgentChatMessageOut 列表
+@router.get("/api/agent-chat-sessions/{session_id}/messages", response_model=list[schemas.AgentChatMessageOut])
# 定义 list_messages 视图函数,接收 session_id 和数据库会话 db(依赖注入方式获取)
+def list_messages(session_id: int, session: Session = Depends(get_session)):
    # 调用仓库方法获取指定会话 session 的数据库对象
+  row = agent_chat_repository.get_session(session, session_id)
    # 如果没有找到对应会话,则抛出 404 异常,并提示“会话不存在”
+  if not row:
+      raise HTTPException(status_code=404, detail="会话不存在")
    # 如果会话存在,则调用仓库方法获取所有消息并返回
+  return agent_chat_repository.list_messages(session, session_id)        

22.4. schemas.py #

app/schemas.py

# 导入枚举类型Enum
from enum import Enum

# 导入Any类型用于类型注解
from typing import Any

# 从pydantic导入基模型BaseModel、字段类型Field、字段校验器field_validator
from pydantic import BaseModel, Field, field_validator


# 定义MCP协议枚举类型
class McpProtocol(str, Enum):
    # 定义stdio协议
    stdio = "stdio"
    # 定义streamable-http协议
    streamable_http = "streamable-http"
    # 定义sse协议
    sse = "sse"


# 定义MCP服务基础模型
class McpServiceBase(BaseModel):
    # 服务名称,字符串类型,必填,长度1~255
    name: str = Field(..., min_length=1, max_length=255)
    # 服务描述,字符串类型,可为空
    description: str | None = None
    # 协议字段,采用McpProtocol枚举
    protocol: McpProtocol
    # 配置信息,类型为字符串键到任意类型的字典
    config: dict[str, Any]

    # 对config字段添加验证器,保证其为字典类型
    @field_validator("config", mode="before")
    @classmethod
    def config_not_empty(cls, v: Any) -> Any:
        # 如果config不是dict类型,则抛出异常
        if not isinstance(v, dict):
            raise ValueError("config必须是JSON对象")
        # 返回config
        return v


# 定义MCP服务创建模型,继承McpServiceBase
class McpServiceCreate(McpServiceBase):
    pass


# 定义MCP服务更新模型
class McpServiceUpdate(BaseModel):
    # 名称,可选,长度1~255
    name: str | None = Field(None, min_length=1, max_length=255)
    # 描述,可选
    description: str | None = None
    # 协议,可选
    protocol: McpProtocol | None = None
    # 配置信息,可选
    config: dict[str, Any] | None = None


# 定义MCP服务输出模型类
class McpServiceOut(BaseModel):
    # MCP服务器的ID
    id: int
    # MCP服务器的名称
    name: str
    # MCP服务器描述
    description: str | None
    # MCP服务器的协议 strreamable-http sse stdio
    protocol: str
    # MCP服务器的配置信息,是字典类型
    config: dict[str, Any]
    # 设置模型配置,允许从ORM对象属性直接读取数据
    model_config = {"from_attributes": True}


# 定义MCP测试请求模型
class McpTestRequest(BaseModel):
    # 协议类型
    protocol: McpProtocol
    # 配置信息,必为dict
    config: dict[str, Any]

    # 对config字段进行验证,必须为字典
    @field_validator("config", mode="before")
    @classmethod
    def config_is_object(cls, v: Any):
        # 如果不是dict类型,抛出异常
        if not isinstance(v, dict):
            raise ValueError("config字段的值必须是JSON对象")
        # 返回config
        return v


# 定义MCP测试响应类型
class McpTestResult(BaseModel):
    # 是否成功
    ok: bool
    # 消息内容
    message: str
    # 工具列表,默认为空列表
    tools: list[dict[str, Any]] = Field(default_factory=list)


class LlmModelBase(BaseModel):
    provider_name: str = Field(..., min_length=1, max_length=255)
    provider_icon: str | None = None
    api_base_url: str = Field(..., min_length=1, max_length=1024)
    api_key: str = Field(..., min_length=1, max_length=1024)
    api_key_url: str | None = Field(None, max_length=1024)
    model_names: list[str] = Field(default_factory=list)

    # 用来对模型名称进行校验和归一化处理 要求模型名全是小写,不能为空,不能重复
    @field_validator("model_names")
    @classmethod
    def normalize_model_names(cls, v: list[str]) -> list[str]:
        out: list[str] = []
        # 用来对模型名进行去重
        seen: set[str] = set()
        for item in v or []:
            name = str(item or "").strip()
            # 过滤空的模型名
            if not name:
                continue
            # 全是小写
            key = name.lower()
            if key in seen:
                continue
            seen.add(key)
            out.append(name)
        return out


class LlmModelCreate(LlmModelBase):
    pass


class LlmModelUpdate(LlmModelBase):
    pass


class LlmModelOut(BaseModel):
    id: int
    provider_name: str
    provider_icon: str | None
    api_base_url: str
    api_key: str
    api_key_url: str
    model_names: list[str]
    # Pydantic的配置,启用从属性赋值(用于ORM模型) 可以实现从ORM的实例直接默认转成Pydanic类的实例
    model_config = {"from_attributes": True}


class UploadImageResult(BaseModel):
    url: str


class LlmModelTestRequest(BaseModel):
    api_base_url: str = Field(..., min_length=1, max_length=1024)
    api_key: str = Field(..., min_length=1, max_length=1024)


class LlmModelTestResult(BaseModel):
    ok: bool
    messsage: str
    # 检测到的模型名称列表,默认为空列表
    models: list[str] = Field(default_factory=list)


# 定义函数,用于规范化 ask_variables 字段,输入为可选的字典列表,返回规范化后的字典列表
def normalize_ask_variables(v):
    # 用于保存处理后的变量字典
  out = []
    # 用于记录已出现过的变量key,实现去重
  seen = set()
    # 遍历输入列表(如果为None则转为空列表)
  for item in v or []:
        # 如果当前元素不是字典类型,则跳过
      if not isinstance(item, dict):
          continue
        # 从字典中获取key字段,去除首尾空白转字符串
      key = str(item.get("key") or "").strip()
        # 如果key为空,则跳过本轮
      if not key:
          continue
        # 如果key已出现,则跳过实现去重
      if key in seen:
          continue
        # 将当前key加入去重集合
      seen.add(key)
        # 获取question字段、去空白
      question = str(item.get("question") or "").strip()
        # 获取label字段、去空白
      label = str(item.get("label") or "").strip()
        # 获取default字段、去空白
      default_value = str(item.get("default") or "").strip()
        # 获取required字段,默认为True
      required = bool(item.get("required", True))
        # 将变量信息归一化后放入输出列表
      out.append(
          {
              "key": key,
              "label": label,
              "question": question or f"请提供 {label or key}",
              "required": required,
              "default": default_value,
          }
      )
    # 返回归一化和去重后的变量列表
  return out


# 定义 AgentBase 基础模型,继承自 Pydantic 的 BaseModel
class AgentBase(BaseModel):
    # 头像字段,可为空
  avatar: str | None = None
    # 智能体名称,必填,长度1~255
  name: str = Field(..., min_length=1, max_length=255)
    # 描述信息,可为空
  description: str | None = None
    # 开场白内容,可为空
  opening_message: str | None = None
    # 智能体系统提示,必填,最小长度1
  system_prompt: str = Field(..., min_length=1)
    # LLM 提供商名称,必填,长度1~255
  llm_provider_name: str = Field(..., min_length=1, max_length=255)
    # LLM 模型名称,必填,长度1~255
  llm_model_name: str = Field(..., min_length=1, max_length=255)
    # 关联的 MCP 服务ID列表,默认为空列表
  mcp_service_ids: list[int] = Field(default_factory=list)
    # 询问提示词模板,可为空
  ask_prompt_template: str | None = None
    # 询问变量列表,默认为空列表
  ask_variables: list[dict[str, Any]] = Field(default_factory=list)

    # 对 mcp_service_ids 字段做归一化校验
  @field_validator("mcp_service_ids")
  @classmethod
  def normalize_mcp_service_ids(cls, v):
        # 存储去重后的有效 id
      out = []
        # 已经出现过的 id 集合
      seen = set()
        # 遍历 id 列表(防止为 None)
      for item in v or []:
            # 转成整数类型
          num = int(item)
            # 跳过小于等于0的无效 id
          if num <= 0:
              continue
            # 跳过重复 id
          if num in seen:
              continue
            # 添加到去重集合
          seen.add(num)
            # 添加到输出集合
          out.append(num)
        # 返回整理后的 id 列表
      return out

    # 对 ask_variables 字段做归一化校验
  @field_validator("ask_variables")
  @classmethod
  def normalize_ask_variables(cls, v):
        # 使用 _normalize_ask_variables 函数处理
      return normalize_ask_variables(v)

# 定义 AgentCreate 创建模型,继承自 AgentBase,无额外字段
class AgentCreate(AgentBase):
  pass        

# 定义AgentOut响应模型,继承自BaseModel
class AgentOut(BaseModel):
    # 主键ID
  id: int
    # 头像,允许为None
  avatar: str | None
    # 智能体名称
  name: str
    # 智能体描述,允许为None
  description: str | None
    # 开场白,允许为None
  opening_message: str | None
    # 智能体系统提示,不可为None
  system_prompt: str
    # LLM提供商名称
  llm_provider_name: str
    # LLM模型名称
  llm_model_name: str
    # 关联的MCP服务ID列表
  mcp_service_ids: list[int]
    # 询问提示词模板,允许为None
  ask_prompt_template: str | None
    # 询问变量列表,默认为空列表
  ask_variables: list[dict[str, Any]] = Field(default_factory=list)

    # Pydantic配置:允许根据对象属性创建模型(ORM模式)
  model_config = {"from_attributes": True}  


# 定义AgentUpdate模型,用于部分更新Agent,继承自Pydantic的BaseModel
class AgentUpdate(BaseModel):
    # 头像字段,允许为None
  avatar: str | None = None
    # 名称字段,允许为None,若不为None则要求长度1~255
  name: str | None = Field(None, min_length=1, max_length=255)
    # 描述字段,允许为None
  description: str | None = None
    # 开场白字段,允许为None
  opening_message: str | None = None
    # 系统提示词字段,允许为None,若不为None则要求最小长度1
  system_prompt: str | None = Field(None, min_length=1)
    # LLM提供商名称,允许为None,若不为None长度1~255
  llm_provider_name: str | None = Field(None, min_length=1, max_length=255)
    # LLM模型名称,允许为None,若不为None长度1~255
  llm_model_name: str | None = Field(None, min_length=1, max_length=255)
    # 关联的MCP服务ID列表,允许为None
  mcp_service_ids: list[int] | None = None
    # 询问提示词模板,允许为None
  ask_prompt_template: str | None = None
    # 询问变量列表,允许为None
  ask_variables: list[dict[str, Any]] | None = None

    # 对mcp_service_ids字段进行校验和去重,允许为None
  @field_validator("mcp_service_ids")
  @classmethod
  def normalize_mcp_service_ids_optional(cls, v: list[int] | None) -> list[int] | None:
        # 如果为None,直接返回None
      if v is None:
          return None
        # 定义用于存放合法、去重后的ID的列表
      out: list[int] = []
        # 定义用于去重的集合
      seen: set[int] = set()
        # 遍历传入的每个ID
      for item in v:
            # 转为整数
          num = int(item)
            # 跳过小于等于0的无效ID
          if num <= 0:
              continue
            # 跳过重复ID
          if num in seen:
              continue
            # 加入去重集合
          seen.add(num)
            # 加入输出列表
          out.append(num)
        # 返回整理后的ID列表
      return out

    # 对ask_variables字段进行归一化与合法性检查,允许为None
  @field_validator("ask_variables")
  @classmethod
  def normalize_ask_variables_optional(cls, v: list[dict[str, Any]] | None) -> list[dict[str, Any]] | None:
        # 如果为None直接返回None
      if v is None:
          return None
        # 使用normalize_ask_variables工具函数归一化处理
      return normalize_ask_variables(v)    

# 定义用于输出AgentChatSession(智能体聊天会话)的Pydantic模型
class AgentChatSessionOut(BaseModel):
    # 会话记录的主键ID
   id: int
    # 关联的Agent智能体ID
   agent_id: int
    # 会话标题
   title: str
    # 会话创建时间
   created_at: Any
    # 会话最近更新时间
   updated_at: Any

    # 配置项:允许Pydantic模型支持数据库ORM对象的属性映射
   model_config = {"from_attributes": True}         

# 定义AgentChatSessionCreate用于创建聊天会话的Pydantic模型
class AgentChatSessionCreate(BaseModel):
    # 可选的会话标题字段,最大长度255个字符,默认为None
   title: str | None = Field(None, max_length=255)      

# 定义用于输出Agent聊天消息的Pydantic模型
+class AgentChatMessageOut(BaseModel):
    # 消息主键ID
+  id: int
    # 关联的聊天会话session_id
+  session_id: int
    # 消息所属角色(如user/assistant/system等)
+  role: str
    # 消息正文内容
+  content: str
    # 扩展元数据,默认为空字典
+  meta: dict[str, Any] = Field(default_factory=dict)
    # 消息创建时间
+  created_at: Any

    # 配置项:允许支持通过ORM对象转为模型
+  model_config = {"from_attributes": True}      

23. 创建对话消息 #

本接口用于向指定的对话会话中添加一条新的消息,通常用于前端用户或后端服务发送文本,对话历史的记录与更新。

路由定义

后端在 app/routers/agent_chat.py 中提供如下接口:

  • 接口地址:POST /api/agent-chat-sessions/{session_id}/messages
  • 请求参数:
    • session_id(路径参数):需要添加消息的会话ID。
    • body(请求体):符合 AgentChatMessageCreate 的消息内容。
  • 响应数据:返回新建的消息数据,结构为 AgentChatMessageOut。

FastAPI 路由示例:

@router.post("/api/agent-chat-sessions/{session_id}/messages", response_model=schemas.AgentChatMessageOut)
def create_message(session_id: int, payload: schemas.AgentChatMessageCreate, session: Session = Depends(get_session)):
    # 检查会话是否存在
    row = agent_chat_repository.get_session(session, session_id)
    if not row:
        raise HTTPException(status_code=404, detail="会话不存在")
    # 校验消息角色(仅支持 user / assistant / tool)
    role = str(payload.role or "").strip().lower()
    if role not in {"user", "assistant", "tool"}:
        raise HTTPException(status_code=400, detail="role 仅支持 user / assistant / tool")
    # 创建消息
    return agent_chat_repository.create_message(session, session_id, role, payload.content, payload.meta)

请求体格式

每次请求需传递如下内容:

{
  "role": "user",
  "content": "你好,AI!",
  "meta": {}
}
  • role:string,消息角色,支持 "user"、"assistant"、"tool"。
  • content:string,消息正文内容。
  • meta:object(可选),额外元数据信息,默认为空对象。

响应数据结构

响应内容类型为 AgentChatMessageOut:

{
  "id": 123,
  "session_id": 2,
  "role": "user",
  "content": "你好,AI!",
  "meta": {},
  "created_at": "2024-06-26T13:33:50"
}

常见返回与错误处理说明

  • 新增消息成功时,返回完整的消息数据结构及创建时间。
  • 如果 session_id 不存在,接口返回 404 错误,detail 为 "会话不存在"。
  • 如果 role 字段不在允许的集合,会返回 400 错误,detail 为 "role 仅支持 user / assistant / tool"。

数据模型说明

请求体采用 AgentChatMessageCreate,定义如下(见 app/schemas.py):

class AgentChatMessageCreate(BaseModel):
    role: str = Field(..., min_length=1, max_length=32)      # 消息角色,必填
    content: str = Field(..., min_length=1)                  # 消息正文,必填
    meta: dict[str, Any] = Field(default_factory=dict)        # 附加元数据,可选

响应模型为 AgentChatMessageOut。

通过此接口,前端可灵活地为指定会话添加消息,实现自由对话和历史追溯。

23.1. agent_chat_repository.py #

app/repositories/agent_chat_repository.py


# 导入 SQLAlchemy 的 func 和 select 方法用于数据库查询
from sqlalchemy import func, select
# 导入 SQLAlchemy 的 Session 用于数据库会话管理
from sqlalchemy.orm import Session

# 从 app 包导入 models 模块,用于数据库模型操作
from app import models


# 定义一个函数,根据 agent_id 查询对应的所有 AgentChatSession 记录
def list_sessions(session: Session, agent_id: int) -> list[models.AgentChatSession]:
    # 构造一个查询语句,筛选 agent_id 等于传入参数的聊天会话
    stmt = select(models.AgentChatSession).where(models.AgentChatSession.agent_id == agent_id)
    # 按照 updated_at 字段倒序、然后 id 字段倒序排序,确保最新的会话排在前面
    stmt = stmt.order_by(models.AgentChatSession.updated_at.desc(), models.AgentChatSession.id.desc())
    # 执行查询,将所有结果转换为列表并返回
    return list(session.scalars(stmt).all())

# 定义创建聊天会话的方法,传入数据库会话 db、智能体ID agent_id、会话标题 title(可选,默认为 None)
def create_session(session: Session, agent_id: int, title: str | None = None) -> models.AgentChatSession:
    # 创建 AgentChatSession 实例,指定 agent_id 和标题(默认为‘新对话’,去除空格后为空也用‘新对话’)
    row = models.AgentChatSession(
        agent_id=agent_id,
        title=(title or "新对话").strip() or "新对话",
    )
    # 将新建的会话对象添加到数据库 session 中,准备写入数据库
    session.add(row)
    # 提交事务,将新添加的会话保存到数据库
    session.commit()
    # 刷新 row 对象,确保获取数据库自动生成的字段(如主键、时间等)的最新值
    session.refresh(row)
    # 返回新创建的会话对象
    return row 

# 定义一个函数,通过 session_id 获取指定的 AgentChatSession 记录
def get_session(session: Session, session_id: int) -> models.AgentChatSession | None:
    # 调用 session.get 方法,根据主键 session_id 查询 AgentChatSession,如果不存在则返回 None
    return session.get(models.AgentChatSession, session_id)   

# 定义删除指定会话及其所有消息的函数
def delete_session(session: Session, row: models.AgentChatSession) -> None:
    # 删除会话记录本身
    session.delete(row)
    # 提交事务,保存删除操作到数据库
    session.commit()

# 定义一个函数,根据会话ID查询对应的所有聊天消息,并按消息ID升序排序
def list_messages(session: Session, session_id: int) -> list[models.AgentChatMessage]:
    # 构造查询语句,只筛选session_id为指定值的消息
   stmt = select(models.AgentChatMessage).where(models.AgentChatMessage.session_id == session_id)
    # 按id字段升序排列消息,确保按先后顺序返回
   stmt = stmt.order_by(models.AgentChatMessage.id.asc())
    # 执行查询并返回所有结果转换为列表
   return list(session.scalars(stmt).all())     

# 定义一个函数用于创建 AgentChatMessage 消息记录
+def create_message(
+  db: Session,                # 数据库会话对象
+  session_id: int,            # 聊天会话ID
+  role: str,                  # 消息角色(如 user、assistant、tool)
+  content: str,               # 消息正文内容
+  meta: dict | None = None,   # 消息附加元数据,默认为None
+) -> models.AgentChatMessage:
    # 创建 AgentChatMessage 实例,去除角色两端空白,meta为None时使用空字典
+  row = models.AgentChatMessage(
+      session_id=session_id,#聊天会话ID
+      role=role.strip(),#消息角色(如 user、assistant、tool)
+      content=content,#消息正文内容
+      meta=meta or {},#消息附加元数据,默认为None
+  )
    # 将新消息加入数据库会话
+  db.add(row)
    # 提交事务,将消息写入数据库
+  db.commit()
    # 刷新对象,获取数据库自动生成的字段(如id、创建时间等)的最新值
+  db.refresh(row)
    # 返回新建的消息对象
+  return row       

23.2. agent_chat.py #

app/routers/agent_chat.py

# 从 fastapi 导入 APIRouter、Depends 和 HTTPException,用于路由定义和依赖注入及异常处理
from fastapi import APIRouter, Depends, HTTPException
# 从 sqlalchemy.orm 导入 Session,用于数据库会话管理
from sqlalchemy.orm import Session
import logging
# 从 app.repositories 导入 agent_chat_repository 和 agent_repository,用于数据持久层访问
from app.repositories import agent_chat_repository, agent_repository
# 从 app 导入 schemas,用于数据模型
from app import schemas
# 从 app.database 导入 get_session,用于获取数据库会话
from app.database import get_session

logger = logging.getLogger(__name__)
# 创建 APIRouter 实例,并设置 tags 标签为 "agent-chat"
router = APIRouter(tags=["agent-chat"])


# 声明 GET 接口,路径为 /api/agents/{agent_id}/chat-sessions,返回值为 AgentChatSessionOut 数据模型列表
@router.get("/api/agents/{agent_id}/chat-sessions", response_model=list[schemas.AgentChatSessionOut])
# 定义 list_sessions 视图函数,接收 agent_id 作为路径参数,db 为依赖注入的 Session 对象
def list_sessions(agent_id: int, session: Session = Depends(get_session)):
    # 先检查数据库里是否存在指定 agent,如果不存在则抛出 404 错误
    if not agent_repository.get_agent(session, agent_id):
        raise HTTPException(status_code=404, detail="智能体不存在")
    # 如果智能体存在,查询所有该 agent 下的对话会话,并将结果返回
    return agent_chat_repository.list_sessions(session, agent_id)    


# 声明 POST 路由,用于创建新的智能体聊天会话,响应模型为 AgentChatSessionOut
@router.post("/api/agents/{agent_id}/chat-sessions", response_model=schemas.AgentChatSessionOut)
# 定义 create_session 函数,接收 agent_id、会话创建载荷 payload、依赖注入的数据库会话 session
def create_session(agent_id: int, payload: schemas.AgentChatSessionCreate, session: Session = Depends(get_session)):
    # 先判断数据库中是否存在指定 agent,如果不存在则抛出 404 错误
    if not agent_repository.get_agent(session, agent_id):
        raise HTTPException(status_code=404, detail="智能体不存在")
    # 如果智能体存在,则调用 agent_chat_repository 创建会话,传入数据库会话、智能体ID和会话标题,返回结果
    return agent_chat_repository.create_session(session, agent_id, payload.title)  


# 声明 delete 路由,路径为 /api/agent-chat-sessions/{session_id}
@router.delete("/api/agent-chat-sessions/{session_id}")
# 定义 delete_session 视图函数,接收 session_id 路径参数和数据库会话 db(依赖注入)
def delete_session(session_id: int, session: Session = Depends(get_session)):
    # 根据 session_id 获取会话对象
    row = agent_chat_repository.get_session(session, session_id)
    # 如果会话不存在,抛出 404 异常并提示“会话不存在”
    if not row:
        raise HTTPException(status_code=404, detail="会话不存在")
    # 会话存在,调用仓库方法删除该会话
    agent_chat_repository.delete_session(session, row)
    # 返回删除成功的响应
    return {"ok": True}        

# 声明一个 GET 路由,根据 session_id 获取会话消息,返回值为 AgentChatMessageOut 列表
@router.get("/api/agent-chat-sessions/{session_id}/messages", response_model=list[schemas.AgentChatMessageOut])
# 定义 list_messages 视图函数,接收 session_id 和数据库会话 db(依赖注入方式获取)
def list_messages(session_id: int, session: Session = Depends(get_session)):
    # 调用仓库方法获取指定会话 session 的数据库对象
   row = agent_chat_repository.get_session(session, session_id)
    # 如果没有找到对应会话,则抛出 404 异常,并提示“会话不存在”
   if not row:
       raise HTTPException(status_code=404, detail="会话不存在")
    # 如果会话存在,则调用仓库方法获取所有消息并返回
   return agent_chat_repository.list_messages(session, session_id)        

# 定义一个 POST 路由,路径为 /api/agent-chat-sessions/{session_id}/messages,响应体为 AgentChatMessageOut 模型
+@router.post("/api/agent-chat-sessions/{session_id}/messages", response_model=schemas.AgentChatMessageOut)
# 定义 create_message 视图函数,参数为 session_id、payload(通过 Pydantic 校验的消息数据),db 为依赖注入的数据库会话
+def create_message(session_id: int, payload: schemas.AgentChatMessageCreate, session: Session = Depends(get_session)):
    # 调用 agent_chat_repository.get_session 检查指定 session_id 的会话是否存在
+  row = agent_chat_repository.get_session(session, session_id)
    # 如果会话不存在,抛出 404 异常并提示“会话不存在”
+  if not row:
+      raise HTTPException(status_code=404, detail="会话不存在")
    # 获取请求的消息角色参数,去除首尾空格并转为小写字符串
+  role = str(payload.role or "").strip().lower()
    # 判断 role 是否在支持的角色集合中(user/assistant/tool),否则抛出 400 异常
+  if role not in {"user", "assistant", "tool"}:
+      raise HTTPException(status_code=400, detail="role 仅支持 user / assistant / tool")
    # 调用 agent_chat_repository.create_message 创建一条消息并返回
+  return agent_chat_repository.create_message(session, session_id, role, payload.content, payload.meta)   

23.3. schemas.py #

app/schemas.py

# 导入枚举类型Enum
from enum import Enum

# 导入Any类型用于类型注解
from typing import Any

# 从pydantic导入基模型BaseModel、字段类型Field、字段校验器field_validator
from pydantic import BaseModel, Field, field_validator


# 定义MCP协议枚举类型
class McpProtocol(str, Enum):
    # 定义stdio协议
    stdio = "stdio"
    # 定义streamable-http协议
    streamable_http = "streamable-http"
    # 定义sse协议
    sse = "sse"


# 定义MCP服务基础模型
class McpServiceBase(BaseModel):
    # 服务名称,字符串类型,必填,长度1~255
    name: str = Field(..., min_length=1, max_length=255)
    # 服务描述,字符串类型,可为空
    description: str | None = None
    # 协议字段,采用McpProtocol枚举
    protocol: McpProtocol
    # 配置信息,类型为字符串键到任意类型的字典
    config: dict[str, Any]

    # 对config字段添加验证器,保证其为字典类型
    @field_validator("config", mode="before")
    @classmethod
    def config_not_empty(cls, v: Any) -> Any:
        # 如果config不是dict类型,则抛出异常
        if not isinstance(v, dict):
            raise ValueError("config必须是JSON对象")
        # 返回config
        return v


# 定义MCP服务创建模型,继承McpServiceBase
class McpServiceCreate(McpServiceBase):
    pass


# 定义MCP服务更新模型
class McpServiceUpdate(BaseModel):
    # 名称,可选,长度1~255
    name: str | None = Field(None, min_length=1, max_length=255)
    # 描述,可选
    description: str | None = None
    # 协议,可选
    protocol: McpProtocol | None = None
    # 配置信息,可选
    config: dict[str, Any] | None = None


# 定义MCP服务输出模型类
class McpServiceOut(BaseModel):
    # MCP服务器的ID
    id: int
    # MCP服务器的名称
    name: str
    # MCP服务器描述
    description: str | None
    # MCP服务器的协议 strreamable-http sse stdio
    protocol: str
    # MCP服务器的配置信息,是字典类型
    config: dict[str, Any]
    # 设置模型配置,允许从ORM对象属性直接读取数据
    model_config = {"from_attributes": True}


# 定义MCP测试请求模型
class McpTestRequest(BaseModel):
    # 协议类型
    protocol: McpProtocol
    # 配置信息,必为dict
    config: dict[str, Any]

    # 对config字段进行验证,必须为字典
    @field_validator("config", mode="before")
    @classmethod
    def config_is_object(cls, v: Any):
        # 如果不是dict类型,抛出异常
        if not isinstance(v, dict):
            raise ValueError("config字段的值必须是JSON对象")
        # 返回config
        return v


# 定义MCP测试响应类型
class McpTestResult(BaseModel):
    # 是否成功
    ok: bool
    # 消息内容
    message: str
    # 工具列表,默认为空列表
    tools: list[dict[str, Any]] = Field(default_factory=list)


class LlmModelBase(BaseModel):
    provider_name: str = Field(..., min_length=1, max_length=255)
    provider_icon: str | None = None
    api_base_url: str = Field(..., min_length=1, max_length=1024)
    api_key: str = Field(..., min_length=1, max_length=1024)
    api_key_url: str | None = Field(None, max_length=1024)
    model_names: list[str] = Field(default_factory=list)

    # 用来对模型名称进行校验和归一化处理 要求模型名全是小写,不能为空,不能重复
    @field_validator("model_names")
    @classmethod
    def normalize_model_names(cls, v: list[str]) -> list[str]:
        out: list[str] = []
        # 用来对模型名进行去重
        seen: set[str] = set()
        for item in v or []:
            name = str(item or "").strip()
            # 过滤空的模型名
            if not name:
                continue
            # 全是小写
            key = name.lower()
            if key in seen:
                continue
            seen.add(key)
            out.append(name)
        return out


class LlmModelCreate(LlmModelBase):
    pass


class LlmModelUpdate(LlmModelBase):
    pass


class LlmModelOut(BaseModel):
    id: int
    provider_name: str
    provider_icon: str | None
    api_base_url: str
    api_key: str
    api_key_url: str
    model_names: list[str]
    # Pydantic的配置,启用从属性赋值(用于ORM模型) 可以实现从ORM的实例直接默认转成Pydanic类的实例
    model_config = {"from_attributes": True}


class UploadImageResult(BaseModel):
    url: str


class LlmModelTestRequest(BaseModel):
    api_base_url: str = Field(..., min_length=1, max_length=1024)
    api_key: str = Field(..., min_length=1, max_length=1024)


class LlmModelTestResult(BaseModel):
    ok: bool
    messsage: str
    # 检测到的模型名称列表,默认为空列表
    models: list[str] = Field(default_factory=list)


# 定义函数,用于规范化 ask_variables 字段,输入为可选的字典列表,返回规范化后的字典列表
def normalize_ask_variables(v):
    # 用于保存处理后的变量字典
  out = []
    # 用于记录已出现过的变量key,实现去重
  seen = set()
    # 遍历输入列表(如果为None则转为空列表)
  for item in v or []:
        # 如果当前元素不是字典类型,则跳过
      if not isinstance(item, dict):
          continue
        # 从字典中获取key字段,去除首尾空白转字符串
      key = str(item.get("key") or "").strip()
        # 如果key为空,则跳过本轮
      if not key:
          continue
        # 如果key已出现,则跳过实现去重
      if key in seen:
          continue
        # 将当前key加入去重集合
      seen.add(key)
        # 获取question字段、去空白
      question = str(item.get("question") or "").strip()
        # 获取label字段、去空白
      label = str(item.get("label") or "").strip()
        # 获取default字段、去空白
      default_value = str(item.get("default") or "").strip()
        # 获取required字段,默认为True
      required = bool(item.get("required", True))
        # 将变量信息归一化后放入输出列表
      out.append(
          {
              "key": key,
              "label": label,
              "question": question or f"请提供 {label or key}",
              "required": required,
              "default": default_value,
          }
      )
    # 返回归一化和去重后的变量列表
  return out


# 定义 AgentBase 基础模型,继承自 Pydantic 的 BaseModel
class AgentBase(BaseModel):
    # 头像字段,可为空
  avatar: str | None = None
    # 智能体名称,必填,长度1~255
  name: str = Field(..., min_length=1, max_length=255)
    # 描述信息,可为空
  description: str | None = None
    # 开场白内容,可为空
  opening_message: str | None = None
    # 智能体系统提示,必填,最小长度1
  system_prompt: str = Field(..., min_length=1)
    # LLM 提供商名称,必填,长度1~255
  llm_provider_name: str = Field(..., min_length=1, max_length=255)
    # LLM 模型名称,必填,长度1~255
  llm_model_name: str = Field(..., min_length=1, max_length=255)
    # 关联的 MCP 服务ID列表,默认为空列表
  mcp_service_ids: list[int] = Field(default_factory=list)
    # 询问提示词模板,可为空
  ask_prompt_template: str | None = None
    # 询问变量列表,默认为空列表
  ask_variables: list[dict[str, Any]] = Field(default_factory=list)

    # 对 mcp_service_ids 字段做归一化校验
  @field_validator("mcp_service_ids")
  @classmethod
  def normalize_mcp_service_ids(cls, v):
        # 存储去重后的有效 id
      out = []
        # 已经出现过的 id 集合
      seen = set()
        # 遍历 id 列表(防止为 None)
      for item in v or []:
            # 转成整数类型
          num = int(item)
            # 跳过小于等于0的无效 id
          if num <= 0:
              continue
            # 跳过重复 id
          if num in seen:
              continue
            # 添加到去重集合
          seen.add(num)
            # 添加到输出集合
          out.append(num)
        # 返回整理后的 id 列表
      return out

    # 对 ask_variables 字段做归一化校验
  @field_validator("ask_variables")
  @classmethod
  def normalize_ask_variables(cls, v):
        # 使用 _normalize_ask_variables 函数处理
      return normalize_ask_variables(v)

# 定义 AgentCreate 创建模型,继承自 AgentBase,无额外字段
class AgentCreate(AgentBase):
  pass        

# 定义AgentOut响应模型,继承自BaseModel
class AgentOut(BaseModel):
    # 主键ID
  id: int
    # 头像,允许为None
  avatar: str | None
    # 智能体名称
  name: str
    # 智能体描述,允许为None
  description: str | None
    # 开场白,允许为None
  opening_message: str | None
    # 智能体系统提示,不可为None
  system_prompt: str
    # LLM提供商名称
  llm_provider_name: str
    # LLM模型名称
  llm_model_name: str
    # 关联的MCP服务ID列表
  mcp_service_ids: list[int]
    # 询问提示词模板,允许为None
  ask_prompt_template: str | None
    # 询问变量列表,默认为空列表
  ask_variables: list[dict[str, Any]] = Field(default_factory=list)

    # Pydantic配置:允许根据对象属性创建模型(ORM模式)
  model_config = {"from_attributes": True}  


# 定义AgentUpdate模型,用于部分更新Agent,继承自Pydantic的BaseModel
class AgentUpdate(BaseModel):
    # 头像字段,允许为None
  avatar: str | None = None
    # 名称字段,允许为None,若不为None则要求长度1~255
  name: str | None = Field(None, min_length=1, max_length=255)
    # 描述字段,允许为None
  description: str | None = None
    # 开场白字段,允许为None
  opening_message: str | None = None
    # 系统提示词字段,允许为None,若不为None则要求最小长度1
  system_prompt: str | None = Field(None, min_length=1)
    # LLM提供商名称,允许为None,若不为None长度1~255
  llm_provider_name: str | None = Field(None, min_length=1, max_length=255)
    # LLM模型名称,允许为None,若不为None长度1~255
  llm_model_name: str | None = Field(None, min_length=1, max_length=255)
    # 关联的MCP服务ID列表,允许为None
  mcp_service_ids: list[int] | None = None
    # 询问提示词模板,允许为None
  ask_prompt_template: str | None = None
    # 询问变量列表,允许为None
  ask_variables: list[dict[str, Any]] | None = None

    # 对mcp_service_ids字段进行校验和去重,允许为None
  @field_validator("mcp_service_ids")
  @classmethod
  def normalize_mcp_service_ids_optional(cls, v: list[int] | None) -> list[int] | None:
        # 如果为None,直接返回None
      if v is None:
          return None
        # 定义用于存放合法、去重后的ID的列表
      out: list[int] = []
        # 定义用于去重的集合
      seen: set[int] = set()
        # 遍历传入的每个ID
      for item in v:
            # 转为整数
          num = int(item)
            # 跳过小于等于0的无效ID
          if num <= 0:
              continue
            # 跳过重复ID
          if num in seen:
              continue
            # 加入去重集合
          seen.add(num)
            # 加入输出列表
          out.append(num)
        # 返回整理后的ID列表
      return out

    # 对ask_variables字段进行归一化与合法性检查,允许为None
  @field_validator("ask_variables")
  @classmethod
  def normalize_ask_variables_optional(cls, v: list[dict[str, Any]] | None) -> list[dict[str, Any]] | None:
        # 如果为None直接返回None
      if v is None:
          return None
        # 使用normalize_ask_variables工具函数归一化处理
      return normalize_ask_variables(v)    

# 定义用于输出AgentChatSession(智能体聊天会话)的Pydantic模型
class AgentChatSessionOut(BaseModel):
    # 会话记录的主键ID
   id: int
    # 关联的Agent智能体ID
   agent_id: int
    # 会话标题
   title: str
    # 会话创建时间
   created_at: Any
    # 会话最近更新时间
   updated_at: Any

    # 配置项:允许Pydantic模型支持数据库ORM对象的属性映射
   model_config = {"from_attributes": True}         

# 定义AgentChatSessionCreate用于创建聊天会话的Pydantic模型
class AgentChatSessionCreate(BaseModel):
    # 可选的会话标题字段,最大长度255个字符,默认为None
   title: str | None = Field(None, max_length=255)      

# 定义用于输出Agent聊天消息的Pydantic模型
class AgentChatMessageOut(BaseModel):
    # 消息主键ID
   id: int
    # 关联的聊天会话session_id
   session_id: int
    # 消息所属角色(如user/assistant/system等)
   role: str
    # 消息正文内容
   content: str
    # 扩展元数据,默认为空字典
   meta: dict[str, Any] = Field(default_factory=dict)
    # 消息创建时间
   created_at: Any

    # 配置项:允许支持通过ORM对象转为模型
   model_config = {"from_attributes": True}      

# 定义用于创建Agent聊天消息的Pydantic模型
+class AgentChatMessageCreate(BaseModel):
    # 消息角色字段(如 user/assistant/tool),必填,最小长度1,最大32
+  role: str = Field(..., min_length=1, max_length=32)
    # 消息内容字段,必填,最小长度1
+  content: str = Field(..., min_length=1)
    # 扩展元数据,默认为空字典
+  meta: dict[str, Any] = Field(default_factory=dict)        

24.服务器处理对话请求 #

本节介绍服务器端处理智能体对话请求的核心流程与主逻辑实现,主要依靠 app/routers/agent_chat.py 文件中的接口。

接口概览 服务器端实现了一系列接口,用于管理与智能体相关的聊天会话(Session)和消息(Message)流程。主要接口包括:

  • 列出某智能体的所有会话
    GET /api/agents/{agent_id}/chat-sessions
    返回与指定 Agent 相关的所有会话列表。

  • 创建新的会话
    POST /api/agents/{agent_id}/chat-sessions
    提供可选标题参数,返回创建好的会话信息。

  • 删除会话
    DELETE /api/agent-chat-sessions/{session_id}
    删除指定的会话。

  • 获取会话所有消息
    GET /api/agent-chat-sessions/{session_id}/messages
    返回指定会话的全部消息。

  • 添加一条消息
    POST /api/agent-chat-sessions/{session_id}/messages
    发送一个消息到会话中(角色可为user/assistant/tool)。

  • 流式发送消息并获得回复
    POST /api/agent-chat-sessions/{session_id}/messages/stream
    支持 Server-Sent Events(SSE),返回 AI 的流式回复。

核心处理流程 以流式对话接口 /api/agent-chat-sessions/{session_id}/messages/stream 为例,主要逻辑如下:

  1. 校验会话与智能体有效性
    首先查询数据库,校验 session_id 是否有效,并从关联会话中拿到智能体(Agent)信息,无效则报错。

  2. 大语言模型配置信息获取
    根据智能体的 llm_provider_name 字段,查询实际可用的模型 API 地址、密钥和模型名称。

  3. 用户消息校验与存储
    判空,然后把用户新输入的消息写入数据库(角色为 "user")。

  4. 首条消息自动命名会话标题
    如果是本会话的第一条消息,且标题为空或默认为“新对话”,则基于问题内容自动生成一个标题。

  5. 构建上下文消息列表
    从数据库拉取历史消息,利用 build_llm_messages_from_history 方法封装整理,便于后续喂给 LLM 进行回复生成。

  6. 流式回复
    这里只是一个占位实现(实际业务可集成真实 LLM 调用并分块发送),流程中主要有:

    • 首先推送“开始”事件。
    • 固定生成一条“回答内容”作为 assistant 回复(实际应为 LLM 结果)。
    • 将 assistant 回复写入数据库。
    • 最后推送“结束”事件。
  7. 会话活跃度更新
    每次有交互后,都刷新会话的活跃时间,便于客户端排序和清理。

SSE 数据格式 所有数据通过 sse(data) 函数编码为 SSE 规范文本,方便前端实时渲染。

输入参数及校验 所有入参均用 Pydantic 约束与校验(包括角色、内容合法性等)。错误信息明确,方便前端处理异常。

以上流程确保了对话智能体服务端数据的安全、有序和可扩展处理,是实现聊天产品后端的基础环节。

24.1. agent_chat.py #

app/services/agent_chat.py

# 将数据库中的会话消息转化为OpenAI Chat格式的消息列表(包含system、tool_call_id字段)
def build_llm_messages_from_history(agent, message_rows):
    # 初始化消息列表,将系统提示词作为第一条消息(role为"system")
    messages = [{"role": "system", "content": str(agent.system_prompt or "").strip()}]
    # 遍历历史消息
    for m in message_rows:
        # 获取消息角色,并转为小写
        role = str(m.role or "").strip().lower()
        # 非合法角色则跳过
        if role not in {"user", "assistant", "tool"}:
            continue
        # 构建基础消息内容
        item = {"role": role, "content": m.content}
        # 追加到消息列表
        messages.append(item)
    # 返回消息列表
    return messages

24.2. agent_chat_repository.py #

app/repositories/agent_chat_repository.py


# 导入 SQLAlchemy 的 func 和 select 方法用于数据库查询
from sqlalchemy import func, select
# 导入 SQLAlchemy 的 Session 用于数据库会话管理
from sqlalchemy.orm import Session

# 从 app 包导入 models 模块,用于数据库模型操作
from app import models


# 定义一个函数,根据 agent_id 查询对应的所有 AgentChatSession 记录
def list_sessions(session: Session, agent_id: int) -> list[models.AgentChatSession]:
    # 构造一个查询语句,筛选 agent_id 等于传入参数的聊天会话
    stmt = select(models.AgentChatSession).where(models.AgentChatSession.agent_id == agent_id)
    # 按照 updated_at 字段倒序、然后 id 字段倒序排序,确保最新的会话排在前面
    stmt = stmt.order_by(models.AgentChatSession.updated_at.desc(), models.AgentChatSession.id.desc())
    # 执行查询,将所有结果转换为列表并返回
    return list(session.scalars(stmt).all())

# 定义创建聊天会话的方法,传入数据库会话 db、智能体ID agent_id、会话标题 title(可选,默认为 None)
def create_session(session: Session, agent_id: int, title: str | None = None) -> models.AgentChatSession:
    # 创建 AgentChatSession 实例,指定 agent_id 和标题(默认为‘新对话’,去除空格后为空也用‘新对话’)
    row = models.AgentChatSession(
        agent_id=agent_id,
        title=(title or "新对话").strip() or "新对话",
    )
    # 将新建的会话对象添加到数据库 session 中,准备写入数据库
    session.add(row)
    # 提交事务,将新添加的会话保存到数据库
    session.commit()
    # 刷新 row 对象,确保获取数据库自动生成的字段(如主键、时间等)的最新值
    session.refresh(row)
    # 返回新创建的会话对象
    return row 

# 定义一个函数,通过 session_id 获取指定的 AgentChatSession 记录
def get_session(session: Session, session_id: int) -> models.AgentChatSession | None:
    # 调用 session.get 方法,根据主键 session_id 查询 AgentChatSession,如果不存在则返回 None
    return session.get(models.AgentChatSession, session_id)   

# 定义删除指定会话及其所有消息的函数
def delete_session(session: Session, row: models.AgentChatSession) -> None:
    # 删除会话记录本身
    session.delete(row)
    # 提交事务,保存删除操作到数据库
    session.commit()

# 定义一个函数,根据会话ID查询对应的所有聊天消息,并按消息ID升序排序
def list_messages(session: Session, session_id: int) -> list[models.AgentChatMessage]:
    # 构造查询语句,只筛选session_id为指定值的消息
   stmt = select(models.AgentChatMessage).where(models.AgentChatMessage.session_id == session_id)
    # 按id字段升序排列消息,确保按先后顺序返回
   stmt = stmt.order_by(models.AgentChatMessage.id.asc())
    # 执行查询并返回所有结果转换为列表
   return list(session.scalars(stmt).all())     

# 定义一个函数用于创建 AgentChatMessage 消息记录
def create_message(
+  session: Session,                # 数据库会话对象
   session_id: int,            # 聊天会话ID
   role: str,                  # 消息角色(如 user、assistant、tool)
   content: str,               # 消息正文内容
   meta: dict | None = None,   # 消息附加元数据,默认为None
) -> models.AgentChatMessage:
    # 创建 AgentChatMessage 实例,去除角色两端空白,meta为None时使用空字典
   row = models.AgentChatMessage(
       session_id=session_id,#聊天会话ID
       role=role.strip(),#消息角色(如 user、assistant、tool)
       content=content,#消息正文内容
       meta=meta or {},#消息附加元数据,默认为None
   )
    # 将新消息加入数据库会话
+  session.add(row)
    # 提交事务,将消息写入数据库
+  session.commit()
    # 刷新对象,获取数据库自动生成的字段(如id、创建时间等)的最新值
+  session.refresh(row)
    # 返回新建的消息对象
+  return row       

# 定义 touch_session 函数,用于更新会话的更新时间戳
+def touch_session(session: Session, row: models.AgentChatSession) -> models.AgentChatSession:
    # 将会话对象的 updated_at 字段设置为当前时间
+  row.updated_at = func.now()
    # 将更新后的会话对象添加到数据库会话
+  session.add(row)
    # 提交事务,使更改生效
+  session.commit()
    # 刷新 row 对象,确保获取到数据库生成的最新字段值
+  session.refresh(row)
   # 返回更新后的会话对象
   return row       

24.3. agent_chat.py #

app/routers/agent_chat.py

# 从 fastapi 导入 APIRouter、Depends 和 HTTPException,用于路由定义和依赖注入及异常处理
from fastapi import APIRouter, Depends, HTTPException
# 从 sqlalchemy.orm 导入 Session,用于数据库会话管理
from sqlalchemy.orm import Session
# 导入 StreamingResponse,用于流式响应
import logging
# 导入 json,用于 JSON 序列化
+import json
# 从 fastapi.responses 导入 StreamingResponse,用于流式响应
+from fastapi.responses import StreamingResponse
# 从 app.repositories 导入 agent_chat_repository 和 agent_repository,用于数据持久层访问
+from app.repositories import agent_chat_repository, agent_repository,llm_repository
# 从 app 导入 schemas,用于数据模型
from app import schemas
# 从 app.database 导入 get_session,用于获取数据库会话
from app.database import get_session
# 从 app.services.agent_chat 导入 build_llm_messages_from_history,用于构建LLM消息列表
+from app.services.agent_chat import build_llm_messages_from_history
logger = logging.getLogger(__name__)
# 创建 APIRouter 实例,并设置 tags 标签为 "agent-chat"
router = APIRouter(tags=["agent-chat"])

# 根据用户提问内容自动生成会话标题的函数
+def build_session_title_from_question(content: str) -> str:
    # 对传入的内容进行去除首尾空白和多余空格,保证标题精简
+  text = " ".join(str(content or "").strip().split())
    # 如果内容为空,则返回默认标题“新对话”
+  if not text:
+      return "新对话"
    # 最大标题长度设置为24个字符
+  max_len = 24
    # 如果内容长度小于等于最大长度,直接返回内容作为标题
+  if len(text) <= max_len:
+      return text
    # 否则,截取前24个字符并加省略号作为标题
+  return f"{text[:max_len]}..."

# 定义一个将数据格式化为 Server-Sent Events (SSE) 协议字符串的函数
+def sse(data):
    # 使用 json.dumps 序列化数据,并确保中文不转义,末尾用两个换行符分隔
+  return f"data: {json.dumps(data, ensure_ascii=False)}\n\n"

# 声明 GET 接口,路径为 /api/agents/{agent_id}/chat-sessions,返回值为 AgentChatSessionOut 数据模型列表
@router.get("/api/agents/{agent_id}/chat-sessions", response_model=list[schemas.AgentChatSessionOut])
# 定义 list_sessions 视图函数,接收 agent_id 作为路径参数,db 为依赖注入的 Session 对象
def list_sessions(agent_id: int, session: Session = Depends(get_session)):
    # 先检查数据库里是否存在指定 agent,如果不存在则抛出 404 错误
    if not agent_repository.get_agent(session, agent_id):
        raise HTTPException(status_code=404, detail="智能体不存在")
    # 如果智能体存在,查询所有该 agent 下的对话会话,并将结果返回
    return agent_chat_repository.list_sessions(session, agent_id)    


# 声明 POST 路由,用于创建新的智能体聊天会话,响应模型为 AgentChatSessionOut
@router.post("/api/agents/{agent_id}/chat-sessions", response_model=schemas.AgentChatSessionOut)
# 定义 create_session 函数,接收 agent_id、会话创建载荷 payload、依赖注入的数据库会话 session
def create_session(agent_id: int, payload: schemas.AgentChatSessionCreate, session: Session = Depends(get_session)):
    # 先判断数据库中是否存在指定 agent,如果不存在则抛出 404 错误
    if not agent_repository.get_agent(session, agent_id):
        raise HTTPException(status_code=404, detail="智能体不存在")
    # 如果智能体存在,则调用 agent_chat_repository 创建会话,传入数据库会话、智能体ID和会话标题,返回结果
    return agent_chat_repository.create_session(session, agent_id, payload.title)  


# 声明 delete 路由,路径为 /api/agent-chat-sessions/{session_id}
@router.delete("/api/agent-chat-sessions/{session_id}")
# 定义 delete_session 视图函数,接收 session_id 路径参数和数据库会话 db(依赖注入)
def delete_session(session_id: int, session: Session = Depends(get_session)):
    # 根据 session_id 获取会话对象
    row = agent_chat_repository.get_session(session, session_id)
    # 如果会话不存在,抛出 404 异常并提示“会话不存在”
    if not row:
        raise HTTPException(status_code=404, detail="会话不存在")
    # 会话存在,调用仓库方法删除该会话
    agent_chat_repository.delete_session(session, row)
    # 返回删除成功的响应
    return {"ok": True}        

# 声明一个 GET 路由,根据 session_id 获取会话消息,返回值为 AgentChatMessageOut 列表
@router.get("/api/agent-chat-sessions/{session_id}/messages", response_model=list[schemas.AgentChatMessageOut])
# 定义 list_messages 视图函数,接收 session_id 和数据库会话 db(依赖注入方式获取)
def list_messages(session_id: int, session: Session = Depends(get_session)):
    # 调用仓库方法获取指定会话 session 的数据库对象
   row = agent_chat_repository.get_session(session, session_id)
    # 如果没有找到对应会话,则抛出 404 异常,并提示“会话不存在”
   if not row:
       raise HTTPException(status_code=404, detail="会话不存在")
    # 如果会话存在,则调用仓库方法获取所有消息并返回
   return agent_chat_repository.list_messages(session, session_id)        

# 定义一个 POST 路由,路径为 /api/agent-chat-sessions/{session_id}/messages,响应体为 AgentChatMessageOut 模型
@router.post("/api/agent-chat-sessions/{session_id}/messages", response_model=schemas.AgentChatMessageOut)
# 定义 create_message 视图函数,参数为 session_id、payload(通过 Pydantic 校验的消息数据),db 为依赖注入的数据库会话
def create_message(session_id: int, payload: schemas.AgentChatMessageCreate, session: Session = Depends(get_session)):
    # 调用 agent_chat_repository.get_session 检查指定 session_id 的会话是否存在
   row = agent_chat_repository.get_session(session, session_id)
    # 如果会话不存在,抛出 404 异常并提示“会话不存在”
   if not row:
       raise HTTPException(status_code=404, detail="会话不存在")
    # 获取请求的消息角色参数,去除首尾空格并转为小写字符串
   role = str(payload.role or "").strip().lower()
    # 判断 role 是否在支持的角色集合中(user/assistant/tool),否则抛出 400 异常
   if role not in {"user", "assistant", "tool"}:
       raise HTTPException(status_code=400, detail="role 仅支持 user / assistant / tool")
    # 调用 agent_chat_repository.create_message 创建一条消息并返回
   return agent_chat_repository.create_message(session, session_id, role, payload.content, payload.meta)   

# 定义 POST 路由,支持对话消息的流式传输,路径为 /api/agent-chat-sessions/{session_id}/messages/stream
+@router.post("/api/agent-chat-sessions/{session_id}/messages/stream")
# 异步视图函数 stream_message,接收 session_id、payload 以及依赖注入的数据库会话 session
+async def stream_message(session_id, payload: schemas.AgentChatSendRequest, session: Session = Depends(get_session)):
    # 根据会话ID在数据库中查询对应的会话记录
+  session_row = agent_chat_repository.get_session(session, session_id)
    # 如果未找到会话,抛出 404 异常并提示“会话不存在”
+  if not session_row:
+      raise HTTPException(status_code=404, detail="会话不存在")  
    # 根据会话记录的 agent_id 查询对应的智能体对象
+  agent = agent_repository.get_agent(session, session_row.agent_id)
    # 如果智能体不存在,抛出 404 异常并提示“智能体不存在”
+  if not agent:
+      raise HTTPException(status_code=404, detail="智能体不存在")    
    # 按照智能体的llm_provider_name查询所需的大语言模型(LLM)
+  llm = llm_repository.get_llm_by_provider_name(session, agent.llm_provider_name)
    # 如果找不到 LLM 提供商,抛出 400 异常,并输出相关的提示信息
+  if not llm:
+      raise HTTPException(status_code=400, detail=f"找不到大语言模型提供商: {agent.llm_provider_name}")   
    # 获取大语言模型的 API 基础地址,去除首尾空白后保存为字符串
+  llm_api_base_url = str(llm.api_base_url or "").strip()
    # 获取大语言模型的 API 密钥,去除首尾空白后保存为字符串
+  llm_api_key = str(llm.api_key or "").strip()
    # 获取当前智能体配置的大语言模型名称,去除首尾空白后保存为字符串
+  llm_model_name = str(agent.llm_model_name or "").strip()
    # 获取用户消息内容,并去除其首尾空白
+  user_content = payload.content.strip()
    # 如果消息内容为空,抛出 400 错误,提示“消息内容不能为空”
+  if not user_content:
+      raise HTTPException(status_code=400, detail="消息内容不能为空") 
    # 查询当前会话已存在的历史消息记录
+  existing_rows = agent_chat_repository.list_messages(session, session_id)
    # 如果没有历史消息,且会话标题为空或为“新对话”,则自动生成并更新会话标题
+  if not existing_rows and str(session_row.title or "").strip() in {"", "新对话"}:
+      agent_chat_repository.update_session_title(session, session_row, build_session_title_from_question(user_content))  
    # 创建一条新的用户消息到当前会话
+  agent_chat_repository.create_message(session, session_id, "user", user_content)      
    # 构建 LLM 消息列表,从历史消息中构建
+  messages = build_llm_messages_from_history(agent, agent_chat_repository.list_messages(session, session_id))
     # 定义异步生成器函数 _event_stream
+  async def _event_stream():
        # 记录流式对话的开始信息,包含 session_id 和所用模型名称
+      logger.info("开始流式对话 session_id=%s 模型=%s", session_id, llm_model_name)
        # 发送流式对话开始事件(SSE 格式)
+      yield sse({"type": "start"})
        # 初始化助手回复字符串为空
+      final_text = "回答内容"
        # 将最终助手回复写入数据库,消息角色为 assistant
+      agent_chat_repository.create_message(session, session_id, "assistant", final_text)
        # 更新当前会话的最后活跃时间
+      agent_chat_repository.touch_session(session, session_row)
        # 记录流式对话结束信息,包括回复内容长度
+      logger.info("流式对话结束 session_id=%s ", session_id)
        # 发送流式对话结束事件(SSE 格式)
+      yield sse({"type": "done"})
    # 返回基于 _event_stream 生成器函数的 StreamingResponse 响应,SSE 文本类型
+  return StreamingResponse(_event_stream(), media_type="text/event-stream")       

24.4. agents.py #

app/routers/agents.py

# 从 fastapi 导入 APIRouter, Depends 以及 HTTPException 异常
from fastapi import APIRouter, Depends, HTTPException
# 从 sqlalchemy.orm 导入 Session 会话对象
from sqlalchemy.orm import Session

# 从 app 包分别导入 schemas 模块
from app import schemas
# 导入数据库依赖获取函数
from app.database import get_session
# 导入 agent_repository、llm_repository 和 mcp_repository,分别处理不同的数据操作
from app.repositories import agent_repository, llm_repository, mcp_repository

# 创建一个 APIRouter 实例,设置路由的前缀和标签
router = APIRouter(prefix="/api/agents", tags=["agents"])

# 校验相关引用有效性(大模型提供商、模型、MCP 服务)
def _validate_refs(session: Session, provider_name: str, model_name: str, mcp_service_ids: list[int]) -> None:
    # 根据提供商名称查询 LLM 提供商数据
    llm_row = llm_repository.get_llm_by_provider_name(session, provider_name.strip())
    # 如果没有找到对应的 LLM 提供商则抛出 HTTP 400 异常
    if not llm_row:
        raise HTTPException(status_code=400, detail=f"大语言模型提供商不存在: {provider_name}")
    # 获取该提供商下的所有模型名,并做字符串清洗
    llm_models = [str(m or "").strip() for m in (llm_row.model_names or [])]
    # 如果传入的模型名称不属于当前提供商的模型,则抛出 HTTP 400 异常
    if model_name.strip() not in llm_models:
        raise HTTPException(status_code=400, detail=f"模型名称不属于提供商 {provider_name}: {model_name}")
    # 遍历所有 mcp_service_ids
    for sid in mcp_service_ids:
        # 校验每一个 mcp_service 是否存在,如果不存在则抛出 400 异常
        if not mcp_repository.get_mcp_service(session, int(sid)):
            raise HTTPException(status_code=400, detail=f"MCP 服务不存在: {sid}")

# 定义创建 agent 的接口,POST 请求,响应体为 AgentOut 模型
@router.post("", response_model=schemas.AgentOut)
def create_agent(payload: schemas.AgentCreate, session: Session = Depends(get_session)):
    # 调用 _validate_refs 校验 Agent 创建时关联的 LLM 提供商、模型、MCP 服务是否有效
    _validate_refs(session, payload.llm_provider_name, payload.llm_model_name, payload.mcp_service_ids)
    # 校验通过后,调用 agent_repository 创建新的 Agent,并返回创建结果
    return agent_repository.create_agent(session, payload)

# 定义 GET 接口用于获取所有 Agent 列表,响应为 AgentOut 对象列表
@router.get("", response_model=list[schemas.AgentOut])
# 定义 list_agents 视图函数,依赖注入数据库会话 session
def list_agents(session: Session = Depends(get_session)):
    # 调用 agent_repository 的 list_agents 方法,获取所有 Agent 数据
    return agent_repository.list_agents(session)    

# 定义更新指定 agent_id 智能体信息的接口,返回更新后的 AgentOut 响应模型
@router.put("/{agent_id}", response_model=schemas.AgentOut)
# update_agent 视图函数,接收 agent_id、更新数据 payload,和数据库会话 session
def update_agent(agent_id: int, payload: schemas.AgentUpdate, session: Session = Depends(get_session)):
    # 根据 agent_id 从数据库查询原有的 agent 记录
  row = agent_repository.get_agent(session, agent_id)
    # 如果未查到该记录,则抛出 404 错误,提示“记录不存在”
  if not row:
      raise HTTPException(status_code=404, detail="记录不存在")

    # 优先使用提交的数据,否则使用原有值,确定最终的 provider 名称
  provider_name = payload.llm_provider_name if payload.llm_provider_name is not None else row.llm_provider_name
    # 优先使用提交的数据,否则使用原有值,确定最终的 model 名称
  model_name = payload.llm_model_name if payload.llm_model_name is not None else row.llm_model_name
    # 优先使用提交的数据,否则使用原有值,确定 mcp_service_ids
  mcp_service_ids = payload.mcp_service_ids if payload.mcp_service_ids is not None else row.mcp_service_ids
    # 校验 provider/model/mcp_service 引用是否合法
  _validate_refs(session, provider_name, model_name, mcp_service_ids)

    # 调用仓库方法更新 agent 记录并返回更新结果
  return agent_repository.update_agent(session, row, payload)        

# 定义一个用于删除指定 agent_id 智能体信息的接口,HTTP 方法为 DELETE
@router.delete("/{agent_id}")
# delete_agent 视图函数,接收 agent_id 与数据库会话 session
def delete_agent(agent_id: int, session: Session = Depends(get_session)):
    # 根据 agent_id 从数据库查询对应的 agent 记录
  row = agent_repository.get_agent(session, agent_id)
    # 如果没有查到对应记录,则抛出 404 异常,并提示“记录不存在”
  if not row:
      raise HTTPException(status_code=404, detail="记录不存在")
    # 调用仓库方法删除该 agent 记录
  agent_repository.delete_agent(session, row)
    # 返回操作成功的响应
  return {"ok": True}   

# 定义一个 GET 接口,根据 agent_id 获取指定智能体信息,返回 AgentOut 响应模型
@router.get("/{agent_id}", response_model=schemas.AgentOut)
# get_agent 视图函数,接收 agent_id 和数据库会话 session 作为参数
def get_agent(agent_id: int, session: Session = Depends(get_session)):
    # 调用 agent_repository 的 get_agent 方法,根据 agent_id 查询数据库中的智能体记录
   row = agent_repository.get_agent(session, agent_id)
    # 如果未查到对应记录,则抛出 404 异常,并返回“记录不存在”信息
   if not row:
       raise HTTPException(status_code=404, detail="记录不存在")
    # 查询成功则返回该智能体的数据库记录
   return row       

24.5. schemas.py #

app/schemas.py

# 导入枚举类型Enum
from enum import Enum

# 导入Any类型用于类型注解
from typing import Any

# 从pydantic导入基模型BaseModel、字段类型Field、字段校验器field_validator
from pydantic import BaseModel, Field, field_validator


# 定义MCP协议枚举类型
class McpProtocol(str, Enum):
    # 定义stdio协议
    stdio = "stdio"
    # 定义streamable-http协议
    streamable_http = "streamable-http"
    # 定义sse协议
    sse = "sse"


# 定义MCP服务基础模型
class McpServiceBase(BaseModel):
    # 服务名称,字符串类型,必填,长度1~255
    name: str = Field(..., min_length=1, max_length=255)
    # 服务描述,字符串类型,可为空
    description: str | None = None
    # 协议字段,采用McpProtocol枚举
    protocol: McpProtocol
    # 配置信息,类型为字符串键到任意类型的字典
    config: dict[str, Any]

    # 对config字段添加验证器,保证其为字典类型
    @field_validator("config", mode="before")
    @classmethod
    def config_not_empty(cls, v: Any) -> Any:
        # 如果config不是dict类型,则抛出异常
        if not isinstance(v, dict):
            raise ValueError("config必须是JSON对象")
        # 返回config
        return v


# 定义MCP服务创建模型,继承McpServiceBase
class McpServiceCreate(McpServiceBase):
    pass


# 定义MCP服务更新模型
class McpServiceUpdate(BaseModel):
    # 名称,可选,长度1~255
    name: str | None = Field(None, min_length=1, max_length=255)
    # 描述,可选
    description: str | None = None
    # 协议,可选
    protocol: McpProtocol | None = None
    # 配置信息,可选
    config: dict[str, Any] | None = None


# 定义MCP服务输出模型类
class McpServiceOut(BaseModel):
    # MCP服务器的ID
    id: int
    # MCP服务器的名称
    name: str
    # MCP服务器描述
    description: str | None
    # MCP服务器的协议 strreamable-http sse stdio
    protocol: str
    # MCP服务器的配置信息,是字典类型
    config: dict[str, Any]
    # 设置模型配置,允许从ORM对象属性直接读取数据
    model_config = {"from_attributes": True}


# 定义MCP测试请求模型
class McpTestRequest(BaseModel):
    # 协议类型
    protocol: McpProtocol
    # 配置信息,必为dict
    config: dict[str, Any]

    # 对config字段进行验证,必须为字典
    @field_validator("config", mode="before")
    @classmethod
    def config_is_object(cls, v: Any):
        # 如果不是dict类型,抛出异常
        if not isinstance(v, dict):
            raise ValueError("config字段的值必须是JSON对象")
        # 返回config
        return v


# 定义MCP测试响应类型
class McpTestResult(BaseModel):
    # 是否成功
    ok: bool
    # 消息内容
    message: str
    # 工具列表,默认为空列表
    tools: list[dict[str, Any]] = Field(default_factory=list)


class LlmModelBase(BaseModel):
    provider_name: str = Field(..., min_length=1, max_length=255)
    provider_icon: str | None = None
    api_base_url: str = Field(..., min_length=1, max_length=1024)
    api_key: str = Field(..., min_length=1, max_length=1024)
    api_key_url: str | None = Field(None, max_length=1024)
    model_names: list[str] = Field(default_factory=list)

    # 用来对模型名称进行校验和归一化处理 要求模型名全是小写,不能为空,不能重复
    @field_validator("model_names")
    @classmethod
    def normalize_model_names(cls, v: list[str]) -> list[str]:
        out: list[str] = []
        # 用来对模型名进行去重
        seen: set[str] = set()
        for item in v or []:
            name = str(item or "").strip()
            # 过滤空的模型名
            if not name:
                continue
            # 全是小写
            key = name.lower()
            if key in seen:
                continue
            seen.add(key)
            out.append(name)
        return out


class LlmModelCreate(LlmModelBase):
    pass


class LlmModelUpdate(LlmModelBase):
    pass


class LlmModelOut(BaseModel):
    id: int
    provider_name: str
    provider_icon: str | None
    api_base_url: str
    api_key: str
    api_key_url: str
    model_names: list[str]
    # Pydantic的配置,启用从属性赋值(用于ORM模型) 可以实现从ORM的实例直接默认转成Pydanic类的实例
    model_config = {"from_attributes": True}


class UploadImageResult(BaseModel):
    url: str


class LlmModelTestRequest(BaseModel):
    api_base_url: str = Field(..., min_length=1, max_length=1024)
    api_key: str = Field(..., min_length=1, max_length=1024)


class LlmModelTestResult(BaseModel):
    ok: bool
    messsage: str
    # 检测到的模型名称列表,默认为空列表
    models: list[str] = Field(default_factory=list)


# 定义函数,用于规范化 ask_variables 字段,输入为可选的字典列表,返回规范化后的字典列表
def normalize_ask_variables(v):
    # 用于保存处理后的变量字典
  out = []
    # 用于记录已出现过的变量key,实现去重
  seen = set()
    # 遍历输入列表(如果为None则转为空列表)
  for item in v or []:
        # 如果当前元素不是字典类型,则跳过
      if not isinstance(item, dict):
          continue
        # 从字典中获取key字段,去除首尾空白转字符串
      key = str(item.get("key") or "").strip()
        # 如果key为空,则跳过本轮
      if not key:
          continue
        # 如果key已出现,则跳过实现去重
      if key in seen:
          continue
        # 将当前key加入去重集合
      seen.add(key)
        # 获取question字段、去空白
      question = str(item.get("question") or "").strip()
        # 获取label字段、去空白
      label = str(item.get("label") or "").strip()
        # 获取default字段、去空白
      default_value = str(item.get("default") or "").strip()
        # 获取required字段,默认为True
      required = bool(item.get("required", True))
        # 将变量信息归一化后放入输出列表
      out.append(
          {
              "key": key,
              "label": label,
              "question": question or f"请提供 {label or key}",
              "required": required,
              "default": default_value,
          }
      )
    # 返回归一化和去重后的变量列表
  return out


# 定义 AgentBase 基础模型,继承自 Pydantic 的 BaseModel
class AgentBase(BaseModel):
    # 头像字段,可为空
  avatar: str | None = None
    # 智能体名称,必填,长度1~255
  name: str = Field(..., min_length=1, max_length=255)
    # 描述信息,可为空
  description: str | None = None
    # 开场白内容,可为空
  opening_message: str | None = None
    # 智能体系统提示,必填,最小长度1
  system_prompt: str = Field(..., min_length=1)
    # LLM 提供商名称,必填,长度1~255
  llm_provider_name: str = Field(..., min_length=1, max_length=255)
    # LLM 模型名称,必填,长度1~255
  llm_model_name: str = Field(..., min_length=1, max_length=255)
    # 关联的 MCP 服务ID列表,默认为空列表
  mcp_service_ids: list[int] = Field(default_factory=list)
    # 询问提示词模板,可为空
  ask_prompt_template: str | None = None
    # 询问变量列表,默认为空列表
  ask_variables: list[dict[str, Any]] = Field(default_factory=list)

    # 对 mcp_service_ids 字段做归一化校验
  @field_validator("mcp_service_ids")
  @classmethod
  def normalize_mcp_service_ids(cls, v):
        # 存储去重后的有效 id
      out = []
        # 已经出现过的 id 集合
      seen = set()
        # 遍历 id 列表(防止为 None)
      for item in v or []:
            # 转成整数类型
          num = int(item)
            # 跳过小于等于0的无效 id
          if num <= 0:
              continue
            # 跳过重复 id
          if num in seen:
              continue
            # 添加到去重集合
          seen.add(num)
            # 添加到输出集合
          out.append(num)
        # 返回整理后的 id 列表
      return out

    # 对 ask_variables 字段做归一化校验
  @field_validator("ask_variables")
  @classmethod
  def normalize_ask_variables(cls, v):
        # 使用 _normalize_ask_variables 函数处理
      return normalize_ask_variables(v)

# 定义 AgentCreate 创建模型,继承自 AgentBase,无额外字段
class AgentCreate(AgentBase):
  pass        

# 定义AgentOut响应模型,继承自BaseModel
class AgentOut(BaseModel):
    # 主键ID
  id: int
    # 头像,允许为None
  avatar: str | None
    # 智能体名称
  name: str
    # 智能体描述,允许为None
  description: str | None
    # 开场白,允许为None
  opening_message: str | None
    # 智能体系统提示,不可为None
  system_prompt: str
    # LLM提供商名称
  llm_provider_name: str
    # LLM模型名称
  llm_model_name: str
    # 关联的MCP服务ID列表
  mcp_service_ids: list[int]
    # 询问提示词模板,允许为None
  ask_prompt_template: str | None
    # 询问变量列表,默认为空列表
  ask_variables: list[dict[str, Any]] = Field(default_factory=list)

    # Pydantic配置:允许根据对象属性创建模型(ORM模式)
  model_config = {"from_attributes": True}  


# 定义AgentUpdate模型,用于部分更新Agent,继承自Pydantic的BaseModel
class AgentUpdate(BaseModel):
    # 头像字段,允许为None
  avatar: str | None = None
    # 名称字段,允许为None,若不为None则要求长度1~255
  name: str | None = Field(None, min_length=1, max_length=255)
    # 描述字段,允许为None
  description: str | None = None
    # 开场白字段,允许为None
  opening_message: str | None = None
    # 系统提示词字段,允许为None,若不为None则要求最小长度1
  system_prompt: str | None = Field(None, min_length=1)
    # LLM提供商名称,允许为None,若不为None长度1~255
  llm_provider_name: str | None = Field(None, min_length=1, max_length=255)
    # LLM模型名称,允许为None,若不为None长度1~255
  llm_model_name: str | None = Field(None, min_length=1, max_length=255)
    # 关联的MCP服务ID列表,允许为None
  mcp_service_ids: list[int] | None = None
    # 询问提示词模板,允许为None
  ask_prompt_template: str | None = None
    # 询问变量列表,允许为None
  ask_variables: list[dict[str, Any]] | None = None

    # 对mcp_service_ids字段进行校验和去重,允许为None
  @field_validator("mcp_service_ids")
  @classmethod
  def normalize_mcp_service_ids_optional(cls, v: list[int] | None) -> list[int] | None:
        # 如果为None,直接返回None
      if v is None:
          return None
        # 定义用于存放合法、去重后的ID的列表
      out: list[int] = []
        # 定义用于去重的集合
      seen: set[int] = set()
        # 遍历传入的每个ID
      for item in v:
            # 转为整数
          num = int(item)
            # 跳过小于等于0的无效ID
          if num <= 0:
              continue
            # 跳过重复ID
          if num in seen:
              continue
            # 加入去重集合
          seen.add(num)
            # 加入输出列表
          out.append(num)
        # 返回整理后的ID列表
      return out

    # 对ask_variables字段进行归一化与合法性检查,允许为None
  @field_validator("ask_variables")
  @classmethod
  def normalize_ask_variables_optional(cls, v: list[dict[str, Any]] | None) -> list[dict[str, Any]] | None:
        # 如果为None直接返回None
      if v is None:
          return None
        # 使用normalize_ask_variables工具函数归一化处理
      return normalize_ask_variables(v)    

# 定义用于输出AgentChatSession(智能体聊天会话)的Pydantic模型
class AgentChatSessionOut(BaseModel):
    # 会话记录的主键ID
   id: int
    # 关联的Agent智能体ID
   agent_id: int
    # 会话标题
   title: str
    # 会话创建时间
   created_at: Any
    # 会话最近更新时间
   updated_at: Any

    # 配置项:允许Pydantic模型支持数据库ORM对象的属性映射
   model_config = {"from_attributes": True}         

# 定义AgentChatSessionCreate用于创建聊天会话的Pydantic模型
class AgentChatSessionCreate(BaseModel):
    # 可选的会话标题字段,最大长度255个字符,默认为None
   title: str | None = Field(None, max_length=255)      

# 定义用于输出Agent聊天消息的Pydantic模型
class AgentChatMessageOut(BaseModel):
    # 消息主键ID
   id: int
    # 关联的聊天会话session_id
   session_id: int
    # 消息所属角色(如user/assistant/system等)
   role: str
    # 消息正文内容
   content: str
    # 扩展元数据,默认为空字典
   meta: dict[str, Any] = Field(default_factory=dict)
    # 消息创建时间
   created_at: Any

    # 配置项:允许支持通过ORM对象转为模型
   model_config = {"from_attributes": True}      

# 定义用于创建Agent聊天消息的Pydantic模型
class AgentChatMessageCreate(BaseModel):
    # 消息角色字段(如 user/assistant/tool),必填,最小长度1,最大32
   role: str = Field(..., min_length=1, max_length=32)
    # 消息内容字段,必填,最小长度1
   content: str = Field(..., min_length=1)
    # 扩展元数据,默认为空字典
   meta: dict[str, Any] = Field(default_factory=dict)        

# 定义 AgentChatSendRequest 类,继承自 BaseModel,用于发送消息请求的输入模型
+class AgentChatSendRequest(BaseModel):
    # 消息内容字段,必填,最小长度为1
+  content: str = Field(..., min_length=1)     

25.重构流式对话结构 #

本节对流式对话接口的数据结构及其实现进行了重构,更加规范了上下文信息的封装和流式消息的产出方式,主要包括以下要点:

  • 新增 StreamChatContext 结构体,用于统一封装流式会话所需的各类必要上下文信息,包括会话对象、LLM服务配置、历史消息、MCP服务等,方便业务代码传递和维护。
  • 定义 sse 工具函数,将给定的数据格式化为符合 SSE (Server-Sent Events) 协议的数据串,确保流式消息推送兼容主流前端方案。
  • 实现异步生成器 iter_agent_chat_sse,按步骤产出标准化的流式事件(如 start、done、error 类型事件),并在助手产出回复时将其及时写入数据库与日志。
  • 优化异常捕获与事件推送,保证对话流的健壮性与问题可追溯性。

通过本次重构,流式消息接口对接和调试更加清晰,扩展新的业务场景时更为方便和安全。

25.1. agent_chat.py #

app/services/agent_chat.py

# 导入异步迭代器类型注解
from typing import AsyncIterator
# 导入日志库
import logging
# 导入正则表达式库
import re
# 导入异步上下文管理器装饰器
from contextlib import asynccontextmanager
# 导入 MCP 客户端会话和标准输入输出服务器参数
from mcp import ClientSession, StdioServerParameters
# 导入 SSE 客户端
from mcp.client.sse import sse_client
# 导入标准输入输出客户端
from mcp.client.stdio import stdio_client
# 导入可流式 HTTP 客户端
from mcp.client.streamable_http import streamable_http_client
# 导入自定义 mcp httpx 工厂
from app.services.mcp_httpx import mcp_httpx_client_factory
# 创建 agent-chat 的 logger
logger = logging.getLogger("agent-chat")
# generate_with_tools 最后一条事件,路由据此收尾;勿对浏览器转发该 type。
AGENT_CHAT_STREAM_RUN_COMPLETE = "agent_chat_stream_run_complete"
# 将数据库中的会话消息转化为OpenAI Chat格式的消息列表(包含system、tool_call_id字段)
def build_llm_messages_from_history(agent, message_rows):
    # 初始化消息列表,将系统提示词作为第一条消息(role为"system")
    messages = [{"role": "system", "content": str(agent.system_prompt or "").strip()}]
    # 遍历历史消息
    for m in message_rows:
        # 获取消息角色,并转为小写
        role = str(m.role or "").strip().lower()
        # 非合法角色则跳过
        if role not in {"user", "assistant", "tool"}:
            continue
        # 构建基础消息内容
        item = {"role": role, "content": m.content}
        # 追加到消息列表
        messages.append(item)
    # 返回消息列表
    return messages

# 工具函数,支持对 service(对象或字典)的属性安全访问
def service_value(service, key, default=None):
    # 如果是字典则直接用 get 方法
   if isinstance(service, dict):
       return service.get(key, default)
    # 否则用 getattr 访问属性
   return getattr(service, key, default)

# 异步上下文管理器,根据服务的协议获取相应的读写流(支持 stdio、sse、http)
@asynccontextmanager
async def mcp_transport_streams(service):
    # 获取协议类型并小写处理
   protocol = str(service_value(service, "protocol", "") or "").strip().lower()
    # 获取配置项,若不存在则为空字典
   cfg = service_value(service, "config", {}) or {}
    # stdio 协议分支
   if protocol == "stdio":
        # 构建 StdioServerParameters
       server = StdioServerParameters(
           command=str(cfg.get("command") or ""),
           args=[str(a) for a in (cfg.get("args") or [])],
           env={str(k): str(v) for k, v in (cfg.get("env") or {}).items()},
       )
        # 创建 stdio 客户端,获取读写流
       async with stdio_client(server) as (read, write):
           yield read, write
    # sse 协议分支
   elif protocol == "sse":
        # 构建 header 字典
       hdrs = {str(k): str(v) for k, v in (cfg.get("headers") or {}).items()}
        # 创建 sse 客户端,获取读写流
       async with sse_client(
           str(cfg.get("url") or ""),
           headers=hdrs,
           httpx_client_factory=mcp_httpx_client_factory,
       ) as (read, write):
           yield read, write
    # 默认为 streamable http 协议
   else:
        # 构建 header 字典
       hdrs = {str(k): str(v) for k, v in (cfg.get("headers") or {}).items()}
        # 获取 url
       url = str(cfg.get("url") or "")
        # 创建支持流式的 httpx 客户端
       async with mcp_httpx_client_factory(headers=hdrs) as http_client:
            # 创建可流式 http 客户端,获取读写流
           async with streamable_http_client(url, http_client=http_client) as (read, write, _):
               yield read, write

# 获取指定服务的全部工具列表(OpenAI Tools 结构)
async def list_tools_for_service(service):
    # 通过 mcp_transport_streams 获取服务连接
   async with mcp_transport_streams(service) as (read, write):
        # 创建 MCP 客户端会话
       async with ClientSession(read, write) as session:
            # 初始化会话
           await session.initialize()
            # 获取工具列表
           tools_result = await session.list_tools()
    # 从结果提取工具原始列表
   raw_tools = getattr(tools_result, "tools", None) or []
    # 输出格式化后的工具列表
   out = []
    # 遍历每个工具,组装为字典
   for t in raw_tools:
       out.append(
           {
               "name": str(getattr(t, "name", "") or ""),
               "description": str(getattr(t, "description", "") or ""),
               "input_schema": getattr(t, "inputSchema", None) or getattr(t, "input_schema", None) or {},
           }
       )
    # 返回全部工具
   return out

# 工具名称归一化(
def normalize_tool_name(service_name, tool_name):
   return f"{service_name}__{tool_name}"

# 工具 schema 格式归一化(非 dict 则生成默认 object)
def normalize_tool_schema(schema):
    # 已经是 dict 直接返回
   if isinstance(schema, dict):
       return schema
    # 否则给一个默认 schema
   return {"type": "object", "properties": {}}

# 构建 OpenAI Tools 列表,并建立名字到服务+工具的映射
async def build_openai_tools(services):
    # tools 为最终的工具列表,mapping 为映射表
   tools = []
   mapping = {}
    # 遍历每个服务
   for service in services:
        # 获取服务名,没有则用 service_id 拼接
       service_name = str(service_value(service, "name", "") or f"service_{service_value(service, 'id', '')}")
        # 获取服务暴露的全部工具
       for t in await list_tools_for_service(service):
            # 原始工具名
           original_name = str(t.get("name") or "").strip()
            # 工具名为空跳过
           if not original_name:
               continue
            # 工具暴露名归一化
           exposed_name = normalize_tool_name(service_name, original_name)
            # 已存在则跳过
           if exposed_name in mapping:
               continue
            # 记录映射关系
           mapping[exposed_name] = {"service": service, "tool_name": original_name, "service_name": service_name}
            # 追加 OpenAI tools 结构
           tools.append(
               {
                   "type": "function",
                   "function": {
                       "name": exposed_name,
                       "description": str(t.get("description") or f"{service_name} / {original_name}"),
                       "parameters": normalize_tool_schema(t.get("input_schema")),
                   },
               }
           )
    # 返回工具列表和映射
   return tools, mapping

# 主流程:异步生成器,流式产出融合 tools 的 delta、tool 结构
async def generate_with_tools(
   api_base_url,#LLM API基础URL
   api_key,#LLM API密钥
   model_name,#LLM模型名称
   base_messages,#LLM消息列表
   mcp_services,#MCP服务列表
) -> AsyncIterator[dict]:
    # 流式多轮工具调用:yield delta / tool_*;最后一条为 AGENT_CHAT_STREAM_RUN_COMPLETE。MCP 在 mcp_services 非空时始终注入。
    # 初始化 tools 相关变量
   tools = []
   tool_mapping = {}
    # 如果服务非空,构建 tools 并注入
   if mcp_services:
       tools, tool_mapping = await build_openai_tools(mcp_services)
        # 日志记录注入的服务数和工具数
       logger.info(
           "MCP 工具已注入模型: 服务数=%s OpenAI tools 条数=%s",
           len(mcp_services),
           len(tools),
       )
   else:
        # 没有服务,不注册任何工具
       logger.info("未向模型注册工具: 智能体未绑定 MCP 服务(绑定服务数=0)")
   final_text = "调用工具生成回答"
   yield {"type": AGENT_CHAT_STREAM_RUN_COMPLETE, "final_text": final_text}    

25.2. agent_chat_stream.py #

app/services/agent_chat_stream.py


# 导入 NamedTuple 类型用于定义结构化数据类型
from typing import NamedTuple
# 导入 logging 用于日志记录
import logging
# 导入 json 用于序列化数据
import json
# 导入 SQLAlchemy 的 Session 用于数据库会话
from sqlalchemy.orm import Session
# 导入 agent_chat_repository 用于操作 agent chat 相关数据
from app.repositories import agent_chat_repository
# 导入 models 用于类型注解
from app import models
# 导入 agent_chat 模块中的常量和函数
from app.services.agent_chat import generate_with_tools
# 导入 agent_chat 模块中的常量
from app.services.agent_chat import AGENT_CHAT_STREAM_RUN_COMPLETE
# 创建一个 logger 实例用于本模块日志记录
logger = logging.getLogger(__name__)

# 定义一个将数据格式化为 SSE 协议字符串的函数
def sse(data: dict) -> str:
    # 使用 json.dumps 序列化数据并拼接成 SSE 格式,ensure_ascii=False 以避免中文被转义
    return f"data: {json.dumps(data, ensure_ascii=False, default=str)}\n\n"

# 定义流式聊天上下文的数据结构
class StreamChatContext(NamedTuple):
    # 当前会话对象
    session_row: models.AgentChatSession
    # LLM API 基础地址
    llm_api_base_url: str
    # LLM API 密钥
    llm_api_key: str
    # LLM 模型名称
    llm_model_name: str
    # 聊天历史消息列表
    messages: list
    # 相关 MCP 服务列表
    mcp_services: list

# 定义异步生成器函数,用于流式产出 SSE 字符串
async def iter_agent_chat_sse(session: Session, session_id: int, ctx: StreamChatContext):
   """执行 generate_with_tools,产出 SSE 字符串;成功结束时写入助手消息与工具摘要。"""
   # 记录流式对话的开始日志,包含 session_id 和模型名称
   logger.info("开始流式对话 session_id=%s 模型=%s", session_id, ctx.llm_model_name)
   # 发送流式对话开始的事件
   yield sse({"type": "start"})
   try:
       # 定义一个变量用于存储助手最终的回复内容
      final_reply = ""
       # 异步遍历 generate_with_tools 生成的每个事件
      async for evt in generate_with_tools(
          ctx.llm_api_base_url,    # LLM API 基础地址
          ctx.llm_api_key,         # LLM API 密钥
          ctx.llm_model_name,      # LLM 模型名称
          ctx.messages,            # 聊天历史消息列表
          ctx.mcp_services,        # 相关 MCP 服务列表
      ):
          et = evt.get("type")
          if et == AGENT_CHAT_STREAM_RUN_COMPLETE:
              final_reply = str(evt.get("final_text") or "").strip()
              continue
           # 将每个事件以 SSE 字符串形式流式输出
          yield sse(evt)
          # 在数据库中新增一条助手消息
      agent_chat_repository.create_message(session, session_id, "assistant", final_reply, meta={})
      # 更新会话的活跃时间
      agent_chat_repository.touch_session(session, ctx.session_row)
      # 记录流式对话结束和助手回复字数的日志
      logger.info("流式对话结束 session_id=%s 助手回复字数=%s", session_id, len(final_reply))
      # 发送结束事件
      yield sse({"type": "done"})
   except Exception as e:  # noqa: BLE001
       # 捕获异常并记录日志
       logger.exception("流式对话失败 session_id=%s", session_id)
       # 发送错误事件和错误消息
       yield sse({"type": "error", "message": str(e)})

25.3. agent_chat_repository.py #

app/repositories/agent_chat_repository.py

# 导入 select 和 func 用于 SQL 查询和函数
from sqlalchemy import select, func
# 导入 Session 用于数据库会话操作
from sqlalchemy.orm import Session
# 导入项目的数据库模型
from app import models
# 导入项目的schemas数据结构
from app import schemas

# 列出指定 agent_id 的所有会话,按更新时间和ID倒序排列
def list_sessions(session: Session, agent_id: int) -> list[models.AgentChatSession]:
    # 构建查询语句,筛选指定 agent_id 的会话
    stmt = select(models.AgentChatSession).where(
        models.AgentChatSession.agent_id == agent_id
    )
    # 按 updated_at 降序、id 降序排列
    stmt = stmt.order_by(
        models.AgentChatSession.updated_at.desc(), models.AgentChatSession.id.desc()
    )
    # 执行查询并返回结果列表
    return list(session.scalars(stmt).all())

# 创建新的会话记录
def create_session(
+   session: Session, agent_id: int, data: schemas.AgentChatSessionCreate
):
    # 根据传入标题构造新会话,若标题为空则默认为"新对话"
    row = models.AgentChatSession(
        agent_id=agent_id, title=(data.title or "新对话").strip() or "新对话"
    )
    # 添加到数据库会话
    session.add(row)
    # 提交事务
    session.commit()
    # 刷新获取数据库生成的字段
    session.refresh(row)
    # 返回新建的会话对象
    return row

# 获取指定 session_id 的会话对象,若不存在则返回 None
def get_session(session: Session, session_id: int) -> models.AgentChatSession | None:
    return session.get(models.AgentChatSession, session_id)

# 删除给定的会话对象
def delete_session(
    session: Session, row: models.AgentChatSession
) -> models.AgentChatSession | None:
    # 从数据库会话中删除该记录
    session.delete(row)
    # 提交删除操作
    session.commit()

# 列出指定会话(session_id)下所有消息,按id升序排列
def list_messages(session: Session, session_id: int) -> list[models.AgentChatMessage]:
    # 构建查询语句,筛选指定会话的消息
    stmt = select(models.AgentChatMessage).where(
        models.AgentChatMessage.session_id == session_id
    )
    # 按 id 升序排列消息
    stmt = stmt.order_by(models.AgentChatMessage.id.asc())
    # 执行查询并返回消息列表
    return list(session.scalars(stmt).all())

# 创建一条新的消息
def create_message(
+   session: Session, session_id: int, role: str, content: str, meta: dict = {},
):
    # 构造新的消息对象,meta 默认用空字典
    row = models.AgentChatMessage(
+       session_id=session_id, role=role, content=content, meta=meta or {}
    )
    # 添加消息到数据库会话
    session.add(row)
    # 提交事务
    session.commit()
    # 刷新消息对象
    session.refresh(row)
    # 返回新建的消息对象
    return row

# 更新会话标题
+def update_session_title(
    session: Session, row: models.AgentChatSession, title: str
+) -> models.AgentChatSession:
    # 若传入标题不为 None,则更新标题
    if title is not None:
        row.title = title
    # 提交更新
    session.commit()
    # 刷新会话对象
    session.refresh(row)
    # 返回更新后的对象
    return row

# 更新会话的活跃时间为当前时间
def touch_session(
    session: Session, row: models.AgentChatSession
) -> models.AgentChatSession:
    # 将会话对象的 updated_at 字段设置为当前时间
    row.updated_at = func.now()
    # 提交更改
    session.commit()
    # 刷新会话对象
    session.refresh(row)
    # 返回更新后的对象
    return row

25.4. agent_chat.py #

app/routers/agent_chat.py

import logging
from urllib.parse import urljoin
from fastapi import APIRouter, Depends, HTTPException
from fastapi.responses import StreamingResponse
import httpx
import json
# 导入流式聊天上下文和迭代器
+from app.services.agent_chat_stream import StreamChatContext, iter_agent_chat_sse
# 导入唯一性错误异常
from sqlalchemy.exc import IntegrityError

# 导入会话对象
from sqlalchemy.orm import Session

# 导入数据库操作
+from app.repositories import agent_repository, agent_chat_repository, llm_repository,mcp_repository

# 获取 应用程序的数据模型
from app import schemas

# 用于获取数据库会话的依赖函数
from app.database import get_session

# 创建API路由,设置前缀和标签
router = APIRouter(tags=["agent-chat"])
# 获取当前的日志记录器
logger = logging.getLogger(__name__)
# 定义 SSE(Server-Sent Events)流响应头
+SSE_STREAM_HEADERS = {
    # 禁用缓存和内容转换
+   "Cache-Control": "no-cache, no-transform",
    # 保持连接不断开
+   "Connection": "keep-alive",
    # 关闭Nginx的响应缓冲,确保数据即时推送
+   "X-Accel-Buffering": "no",
+}

# 根据用户的提问自动生成会话标题
def build_session_title_from_question(content: str):
    # 对传入的内容进行去除首尾空白和多余的空格,保证标题的精简  a    b c
    text = " ".join(str(content or "").strip().split())
    if not text:
        return "新对话"
    max_len = 24
    if len(text) <= max_len:
        return text
    return f"{text[:max_len]}..."


# 将数据库里的会话消息转化成OPENAI的Chat消息格式
def build_llm_messages_from_history(agent, message_rows):
    # 初始化消息列表,将智能体的系统提示词作为第一条消息
    messages = [{"role": "system", "content": str(agent.system_prompt or "").strip()}]
    # 遍历数据库里的消息列表
    for message_row in message_rows:
        # 获取消息的角色
        role = str(message_row.role or "").strip().lower()
        # 非合法角色则跳过
        if role not in {"user", "assistant", "tool"}:
            continue
        message = {"role": role, "content": message_row.content}
        messages.append(message)
    return messages


# 定义一个将字典数据格式化为Server Sent Event(SSE)协议的字符串函数
def _sse(data):
    # 使用json.dumps序列化数据,并确保中文不转义,末尾两个换行符表示分隔
    return f"data: {json.dumps(data,ensure_ascii=False)}\n\n"

# 定义 mcp_service_dicts 方法,根据智能体的 mcp_service_ids 获取微服务的信息字典列表
+def mcp_service_dicts(session, agent):
    # 初始化结果列表
+   out = []
    # 遍历智能体关联的所有 mcp_service_id,如果字段为 None,则使用空列表
+   for sid in agent.mcp_service_ids or []:
        # 根据 sid 从数据库获取对应的微服务配置行
+       row = mcp_repository.get_mcp_service(session, int(sid))
        # 如果查询到有效的微服务行,则构造字典并添加到结果列表中
+       if row:
+           out.append(
+               {
                    # MCP服务主键ID,转为int
+                   "id": int(row.id),
                    # MCP服务名称,若为空则用空字符串
+                   "name": str(row.name or ""),
                    # MCP服务通信协议,若为空用空字符串
+                   "protocol": str(row.protocol or ""),
                    # MCP服务配置信息,若为空则用空字典
+                   "config": row.config or {},
+               }
+           )
    # 返回所有有效MCP服务信息的字典列表
+   return out
@router.get(
    "/api/agents/{agent_id}/chat-sessions",
    response_model=list[schemas.AgentChatSessionOut],
)
def list_sessions(agent_id: int, session: Session = Depends(get_session)):
    if not agent_repository.get_agent(session, agent_id):
        raise HTTPException(status_code=404, detail="此智能体不存在")
    return agent_chat_repository.list_sessions(session, agent_id)


@router.post(
    "/api/agents/{agent_id}/chat-sessions",
    response_model=schemas.AgentChatSessionOut,
)
def create_session(
    agent_id: int,
    payload: schemas.AgentChatSessionCreate,
    session: Session = Depends(get_session),
):
    if not agent_repository.get_agent(session, agent_id):
        raise HTTPException(status_code=404, detail="此智能体不存在")
    return agent_chat_repository.create_session(session, agent_id, payload)


@router.delete("/api/agent-chat-sessions/{session_id}")
def delete_session(session_id: int, session: Session = Depends(get_session)):
    row = agent_chat_repository.get_session(session, session_id)
    if not row:
        raise HTTPException(status_code=404, detail="此智能体的会话不存在")
    agent_chat_repository.delete_session(session, row)
    return {"ok": True}


@router.get(
    "/api/agent-chat-sessions/{session_id}/messages",
    response_model=list[schemas.AgentChatMessageOut],
)
def list_messages(session_id: int, session: Session = Depends(get_session)):
    row = agent_chat_repository.get_session(session, session_id)
    if not row:
        raise HTTPException(status_code=404, detail="此会话不存在")
    return agent_chat_repository.list_messages(session, session_id)


# 为此会话添加一条消息 第一条消息就是开场白
@router.post(
    "/api/agent-chat-sessions/{session_id}/messages",
    response_model=schemas.AgentChatMessageOut,
)
def create_message(
    session_id: int,
    payload: schemas.AgentChatMessageCreate,
    session: Session = Depends(get_session),
):
    row = agent_chat_repository.get_session(session, session_id)
    if not row:
        raise HTTPException(status_code=404, detail="此会话不存在")
+   return agent_chat_repository.create_message(session, session_id,payload.role, payload.content, payload.meta)

# 定义一个函数用于准备流式聊天上下文
+def prepare_stream_chat(session: Session, session_id: int, payload: schemas.AgentChatSendRequest) -> StreamChatContext:
    # 校验会话和大模型环境,如果需要则更新会话标题、写入用户消息、组装历史消息
    # 从数据库获取会话对象
    session_row = agent_chat_repository.get_session(session, session_id)
    # 如果会话不存在,则抛出404异常
    if not session_row:
        raise HTTPException(status_code=404, detail="会话不存在")
    # 获取与会话关联的智能体对象
    agent = agent_repository.get_agent(session, session_row.agent_id)
    # 如果智能体不存在,则抛出404异常
    if not agent:
+       raise HTTPException(status_code=404, detail="智能体不存在")
    # 获取LLM提供商对应的大语言模型配置
    llm = llm_repository.get_llm_by_provider_name(session, agent.llm_provider_name)
    # 如果找不到对应的大语言模型提供商,则抛出400异常
    if not llm:
+       raise HTTPException(status_code=400, detail=f"找不到大语言模型提供商: {agent.llm_provider_name}")

    # 获取用户输入内容并去除前后空白符
    user_content = payload.content.strip()
    # 如果消息内容为空,则抛出400异常
    if not user_content:
+       raise HTTPException(status_code=400, detail="消息内容不能为空")

    # 判断当前会话是否还没有任何消息
+   had_no_messages = not agent_chat_repository.list_messages(session, session_id)
    # 如果是新会话且标题为空或为默认标题,则根据用户提问生成会话标题并更新
+   if had_no_messages and str(session_row.title or "").strip() in {"", "新对话"}:
+       agent_chat_repository.update_session_title(session, session_row, build_session_title_from_question(user_content))

    # 新增一条用户消息到会话消息表
+   agent_chat_repository.create_message(session, session_id, "user", user_content)
    # 获取最新的历史消息记录,并格式化为模型可用的messages结构
+   messages = build_llm_messages_from_history(agent, agent_chat_repository.list_messages(session, session_id))

    # 返回组装好的 StreamChatContext 上下文对象
+   return StreamChatContext(
+       session_row=session_row,#会话对象   
+       llm_api_base_url=str(llm.api_base_url or "").strip(),#LLM API基础URL
+       llm_api_key=str(llm.api_key or "").strip(),#LLM API密钥
+       llm_model_name=str(agent.llm_model_name or "").strip(),#LLM模型名称
+       messages=messages,#LLM消息列表
+       mcp_services=mcp_service_dicts(session, agent),#MCP服务列表
    )

# 声明POST路由,路径为/api/agent-chat-sessions/{session_id}/messages/stream
+@router.post("/api/agent-chat-sessions/{session_id}/messages/stream")
# 定义异步函数处理流式对话消息
+async def stream_message(session_id, payload: schemas.AgentChatSendRequest, session: Session = Depends(get_session)):
    # 调用prepare_stream_chat组装上下文
+   ctx = prepare_stream_chat(session, session_id, payload)
    # 返回StreamingResponse响应,实现SSE文本事件流
+   return StreamingResponse(
+       iter_agent_chat_sse(session, session_id, ctx),
+       media_type="text/event-stream",
+       headers=SSE_STREAM_HEADERS
+   )

25.5 执行流程 #

25.5.1 整体在做什么 #

客户端对 POST /api/agent-chat-sessions/{session_id}/messages/stream 发一条用户消息;服务端在同一次请求里:

  1. 校验会话 / 智能体 / LLM 配置,必要时改标题,先落库用户消息,拼好发给模型的 messages 和 MCP 服务列表;
  2. 用 SSE(text/event-stream) 持续推送 JSON 事件;
  3. 中间由 generate_with_tools(设计上是 LLM + 可选 MCP 工具)产出结构化事件,经 iter_agent_chat_sse 转成 data: {...}\n\n;
  4. 流正常结束后写 assistant 消息、刷新会话时间,再发 done。

25.5.2 入口:stream_message #

  • 依赖:session_id(路径)、payload: AgentChatSendRequest、Session = Depends(get_session)。
  • 步骤:
    1. ctx = prepare_stream_chat(session, session_id, payload) —— 同步阶段完成校验与 DB 写入、拼 StreamChatContext。
    2. return StreamingResponse(iter_agent_chat_sse(...), media_type="text/event-stream", headers=SSE_STREAM_HEADERS)。

SSE_STREAM_HEADERS:Cache-Control: no-cache, no-transform、Connection: keep-alive、X-Accel-Buffering: no,减少代理缓冲、利于实时推送。

25.5.3 同步准备:prepare_stream_chat #

步骤 行为
加载数据 get_session → 会话;get_agent → 智能体;get_llm_by_provider_name → LLM 行
失败 会话/智能体缺失 → 404;LLM 找不到 → 400;payload.content 空 → 400
标题 若会话尚无消息且标题为空或「新对话」→ 用 build_session_title_from_question 更新标题
落库 create_message(..., "user", user_content)
拼消息 build_llm_messages_from_history(agent, list_messages(...)):首条 system(agent.system_prompt),再按库里的 user / assistant / tool 追加
MCP mcp_service_dicts(session, agent):按 agent.mcp_service_ids 查库,得到 {id, name, protocol, config} 列表
返回 StreamChatContext(session_row, llm_api_*, llm_model_name, messages, mcp_services)

25.5.4 SSE 层:iter_agent_chat_sse #

  • sse(dict):json.dumps(..., ensure_ascii=False, default=str) → data: ...\n\n。
  • 顺序:
    1. 打日志 → yield sse({"type": "start"})。
    2. try:async for evt in generate_with_tools(...) → 每个 evt 再 yield sse(evt)。
    3. 循环结束后:create_message(..., "assistant", final_reply)、touch_session、yield sse({"type": "done"})。
    4. except:logger.exception → yield sse({"type": "error", "message": ...})。

25.5.5 MCP 与 build_openai_tools #

25.5.5.1 辅助能力 #
  • service_value(service, key):兼容 dict 与对象属性。
  • mcp_transport_streams(service):按 protocol 建连接并 yield (read, write):
    • stdio → stdio_client(StdioServerParameters(...))
    • sse → sse_client(url, headers, httpx_client_factory=...)
    • 否则 → streamable_http_client + 自定义 mcp_httpx_client_factory
  • list_tools_for_service(service):ClientSession → initialize() → list_tools(),把每个工具收成 {name, description, input_schema}。
25.5.5.2 名称与 Schema #
  • normalize_tool_name(service_name, tool_name):合成 服务__工具 形式,最长 64,避免多服务同名工具冲突。
  • normalize_tool_schema(schema):非 dict 则退回 {"type":"object","properties":{}}。
25.5.5.3 build_openai_tools(services)(核心) #

对每个 MCP service:

  1. 解析展示用 service_name(缺省用 service_{id})。
  2. for t in await list_tools_for_service(service):
    • 跳过空 name;
    • exposed_name = normalize_tool_name(...),若已在 mapping 则跳过(去重);
    • mapping[exposed_name] = {service, tool_name: 原始名, service_name} —— 以后模型选中 function.name 时,用此表反查连哪个服务、调哪个真实工具;
    • 往 tools 里追加 OpenAI 风格项:type: function,function.name/description/parameters。

返回:(tools, mapping),供后续LLM 请求体使用。

25.5.6 generate_with_tools:设计意图 #

  • 初始化 tools = [], tool_mapping = {}。
  • mcp_services 非空:tools, tool_mapping = await build_openai_tools(mcp_services),并打「服务数 / tools 条数」日志。
  • 为空:打「未注册工具」日志。

  • 流式多轮工具调用;

  • yield delta、tool_* 等字典事件;
  • 最后一条为完成标记(注释中的 AGENT_CHAT_STREAM_RUN_COMPLETE)。

25.5.7 端到端时序图(含 MCP 工具拉取) #

sequenceDiagram participant C as 客户端 participant API as FastAPI participant prep as prepare_stream_chat participant DB as DB / Repository participant iter as iter_agent_chat_sse participant gen as generate_with_tools participant build as build_openai_tools participant MCP as MCP 服务 C->>API: POST .../messages/stream API->>prep: prepare_stream_chat prep->>DB: 会话 / 智能体 / LLM alt 校验失败 prep-->>C: 4xx end prep->>DB: create_message(user) prep->>prep: build_llm_messages_from_history prep->>DB: mcp_service_dicts(读 MCP 配置) prep-->>API: StreamChatContext API-->>C: StreamingResponse text/event-stream iter->>iter: log 开始 iter-->>C: SSE type=start iter->>gen: async for generate_with_tools(...) alt mcp_services 非空 gen->>build: build_openai_tools loop 每个 service build->>MCP: list_tools(经 transport) MCP-->>build: 工具元数据 end build-->>gen: tools, tool_mapping end Note over gen: 当前:此处之后无 yield<br/>设计:LLM 流式 + 工具轮询 gen-->>iter: (理想:多个 evt) loop 每个 evt iter-->>C: SSE data=evt end iter->>DB: create_message(assistant, final_reply) iter->>DB: touch_session iter-->>C: SSE type=done

25.5.8 prepare_stream_chat 决策流 #

flowchart TD A[prepare_stream_chat] --> B{会话存在?} B -->|否| E404[404] B -->|是| C{智能体存在?} C -->|否| E404b[404] C -->|是| D{LLM 配置存在?} D -->|否| E400[400] D -->|是| F{用户内容非空?} F -->|否| E400b[400] F -->|是| G[可选:更新标题] G --> H[create_message user] H --> I[build_llm_messages_from_history] I --> J[mcp_service_dicts] J --> K[返回 StreamChatContext]

25.5.9 build_openai_tools 数据流 #

flowchart LR subgraph in [输入] S[mcp_services] end subgraph loop [每个 service] L[list_tools_for_service] N[normalize_tool_name] SCH[normalize_tool_schema] end subgraph out [输出] T[tools: OpenAI functions] M[mapping: exposed_name → 真实调用信息] end S --> L L --> N N --> SCH SCH --> T N --> M

25.5.10 模块职责一览 #

模块 文件 职责
stream_message agent_chat.py(路由) 组 ctx,挂 SSE 响应
prepare_stream_chat 同上 校验、写用户消息、拼 messages 与 MCP 列表
build_llm_messages_from_history agent_chat.py DB 消息 → Chat API 风格列表
mcp_service_dicts 路由 智能体绑定的 MCP 配置字典列表
iter_agent_chat_sse agent_chat_stream.py start / 透传事件 / 落库 assistant / touch / done / error
mcp_transport_streams 等 agent_chat.py 连接 MCP(stdio/SSE/HTTP)
list_tools_for_service 同上 拉单个服务的工具列表
build_openai_tools 同上 多服务工具合并 + 命名去冲突 + tools/mapping
generate_with_tools 同上 当前仅构建 tools/tool_mapping;流式 LLM 与 yield 待实现

26.调用大模型回答 #

本节将详细介绍如何在 MCP 平台中通过流式接口,调用大模型(如 OpenAI、Deepseek 等)实现即时回答,支持工具调用、多轮上下文以及 Server-Sent Events (SSE) 格式实时推送效果。

使用 generate_with_tools 实现流式大模型调用

generate_with_tools 是核心的异步生成器,它负责按 OpenAI Chat/Function Calling 兼容方式,实现与 LLM 的流式对话,包括工具的动态注入、分段消息逐步产出等,支持如下能力:

  • 多服务工具注入:自动将当前 Agent 绑定的 MCP 服务整合成 OpenAI 风格的 tools 列表,实现统一的工具调用入口。
  • 流式多轮输出:通过 AsyncIterator/yield,不断产出内容 delta,每步可实时推向前端(如 SSE/WebSocket)。
  • 兼容 OpenAI function调用协议:产出内容及工具调用结构,前端可按 OpenAI 官方文档直接适配实现。
  • 工具/消息安全命名:针对不同服务和工具,自动归一化生成唯一工具名,防止命名冲突。

关键步骤说明

  1. 构建 tools 与 tool_mapping

    • 遍历 MCP 绑定服务,收集所有暴露工具,生成兼容 Chat API 的 function schemas。
    • 工具名自动加上服务名前缀去重(如 service1_toolA)。
  2. 与大模型的流式对话

    • 异步请求 LLM 的 Chat 接口,开启 stream 模式,yield 每个内容增量。
    • 若模型响应中检查到工具调用,则额外产出相关事件结构。
  3. 事件流说明

    • type=delta:模型分块输出内容文本(连续片段)。
    • type=AGENT_CHAT_ROUND_DONE:当前一轮完整输出,包括最终消息和所有工具调用(如有)。
    • type=agent_chat_stream_run_complete:全部流式消息发送完成。

通用接口调用流程举例

  1. 准备好历史消息(含用户提问、上下文、可选工具调用历史),整理为标准的 OpenAI messages 格式数组。
  2. 动态生成 tools 列表(如需支持工具调用)。
  3. 调用 generate_with_tools 异步生成器,循环按需异步拉取每个新片段(适合用于 SSE/WebSocket 持续推送)。
  4. 根据事件 type 判断是否还有后续文本,或已结束本轮对话。

通过本机制,可在 MCP 后端便捷集成多家 LLM 服务能力,并实现与现代对话模型一致的流式与工具调用业务体验,前后端开发对接十分顺滑。

26.1. agent_chat.py #

app/services/agent_chat.py

# 引入异步迭代器的类型注解
from typing import AsyncIterator
# 引入openai相关异常与异步客户端
from openai import APIStatusError, AsyncOpenAI
# 引入异步上下文管理器
from contextlib import asynccontextmanager
# 引入MCP相关客户端Session和参数
from mcp import ClientSession, StdioServerParameters
# 引入MCP SSE客户端
from mcp.client.sse import sse_client
# 引入MCP stdio客户端
from mcp.client.stdio import stdio_client
# 引入MCP基于可流式HTTP的客户端
from mcp.client.streamable_http import streamable_http_client
# 引入自定义的httpx工厂方法
from app.services.mcp_httpx import mcp_httpx_client_factory
# 引入日志模块
import logging
# 导入 OpenAI 相关异常和异步客户端
+from openai import APIStatusError, AsyncOpenAI
# 初始化日志记录器,记录当前文件
logger = logging.getLogger(__file__)
# 定义一个常量,表示generate_with_tools的最后一个事件,用于路由判别流式结束(不要把这个事件直接发给客户端)
AGENT_CHAT_STREAM_RUN_COMPLETE = "AGENT_CHAT_STREAM_RUN_COMPLETE"
# 一轮对话结束
+AGENT_CHAT_ROUND_DONE = "AGENT_CHAT_ROUND_DONE"

# 工具函数,安全访问service的属性或键(对象支持点号访问,字典支持下标访问)
def service_value(service, key, default=None):
    # 如果是字典,使用get方法,否则用getattr
    if isinstance(service, dict):
        return service.get(key, default)
    return getattr(service, key, default)


# MCP服务器连接适配器,根据协议生成对应流式读写对象
@asynccontextmanager
async def mcp_transport_streams(mcp_service):
    # 获取协议类型(如stdio、sse、http等),并转为小写
    protocol = service_value(mcp_service, "protocol", "").strip().lower()
    # 获取配置字典
    cfg = mcp_service["config"]
    # 如果协议为stdio,通过stdio_client创建进程
    if protocol == "stdio":
        # 构造StdioServerParameters对象
        stdioServerParameters = StdioServerParameters(
            command=str(cfg.get("command") or ""),
            args=[str(arg) for arg in (cfg.get("args", []))],
            env={str(k): str(v) for k, v in cfg.get("env", {}).items()},
        )
        # 使用stdio_client异步上下文获取读写流
        async with stdio_client(stdioServerParameters) as (read, write):
            yield read, write
    # 如果协议为sse(Server-Sent Events)
    elif protocol == "sse":
        # 构造请求头
        headers = {str(k): str(v) for k, v in (cfg.get("headers") or {}).items()}
        # 创建SSE客户端获取读写流
        async with sse_client(
            str(cfg.get("url") or ""),
            headers=headers,
            httpx_client_factory=mcp_httpx_client_factory,
        ) as (read, write):
            yield read, write
    # 其他协议,默认流式HTTP
    else:
        # 构造请求头
        headers = {str(k): str(v) for k, v in (cfg.get("headers") or {}).items()}
        # 获取url
        url = cfg.get("url") or ""
        # 创建streamable http客户端
        async with mcp_httpx_client_factory(headers=headers) as http_client:
            async with streamable_http_client(url, http_client=http_client) as (
                read,
                write,
                _,
            ):
                yield read, write


# 获取指定MCP服务器对应的全部工具列表
async def list_tools_for_service(mcp_service):
    # 使用异步上下文连接MCP服务,获取读写流
    async with mcp_transport_streams(mcp_service) as (read, write):
        # 创建ClientSession进行会话
        async with ClientSession(read, write) as session:
            # 初始化会话
            await session.initialize()
            # 异步获取工具列表
            tools_result = await session.list_tools()
            # 从结果中取出原始工具列表
            raw_tools = getattr(tools_result, "tools", None) or []
            out = []
            # 遍历所有工具,构造成统一格式
            for tool in raw_tools:
                out.append(
                    {
                        "name": str(getattr(tool, "name", "") or ""),
                        "description": str(getattr(tool, "description", "") or ""),
                        "input_schema": getattr(tool, "inputSchema", None)
                        or getattr(tool, "input_schema", None)
                        or {},
                    }
                )
            # 返回统一后的工具列表
            return out


# 工具名称规范化(归一化)为 service_name__tool_name 形式
def normalize_tool_name(service_name, tool_name):
    return f"{service_name}__{tool_name}"


# 统一工具的输入schema格式(需为字典,否则给默认对象结构)
def normalize_tool_schema(schema):
    if isinstance(schema, dict):
        return schema
    return {"type": "object", "properties": {}}


# 构建OpenAI标准格式的工具列表及其映射(openai函数格式)
async def build_openai_tools(mcp_services):
    # 初始化工具列表和映射字典
    tools = []
    tools_mapping = {}
    # 遍历所有MCP服务,收集其暴露的工具
    for mcp_service in mcp_services:
        # 获取服务名,如果没有name则用service_id
        mcp_service_name = (
            str(service_value(mcp_service, "name", ""))
            or f"service_{service_value(mcp_service, "id", "")}"
        )
        # 获取当前服务实际暴露出的所有工具
        for tool in await list_tools_for_service(mcp_service):
            # 获取原始工具名称,去除空白
            original_name = str(tool.get("name", "") or "").strip()
            # 如果没有工具名,跳过
            if not original_name:
                continue
            # 用于暴露给大模型调用的标准工具名称
            exposed_name = normalize_tool_name(mcp_service_name, original_name)
            # 已收录该工具,则跳过
            if exposed_name in tools_mapping:
                continue
            # 记录该工具的服务、工具名称、服务名等信息
            tools_mapping[exposed_name] = {
                "mcp_service": mcp_service,  # MCP服务对象
                "tool_name": original_name,  # MCP原始工具名
                "mcp_service_name": mcp_service_name,  # MCP服务名
            }
            # 按OpenAI函数格式追加工具描述
            tools.append(
                {
                    "type": "function",
                    "function": {
                        "name": exposed_name,  # 函数名称
                        "description": str(
                            tool.get("description")
                            or f"{mcp_service_name}/{original_name}"
                        ),  # 函数描述
                        # 工具的输入schema作为openai函数的参数定义
                        "parameters": normalize_tool_schema(
                            tool.get("input_schema")
                        ),  # 参数定义
                    },
                }
            )
    # 返回工具列表和工具名映射
    return tools, tools_mapping


# 定义一个函数,将给定的 API 基础地址标准化为以 /v1 结尾,以兼容 OpenAI 官方 SDK(不包含 chat/completions)
+def openai_base_url(api_base_url):
    # 将输入的 api_base_url 转为字符串,去除首尾空白并移除末尾斜杠
+ base = str(api_base_url or "").strip().rstrip("/")
    # 如果处理后的 base 为空,则返回空字符串
+ if not base:
+     return ""
    # 将 base 字符串转换为小写形式
+ lower = base.lower()
    # 如果 base 已经以 /v1 结尾,则直接返回
+ if lower.endswith("/v1"):
+     return base
    # 否则在末尾补上 /v1 并返回
+ return f"{base}/v1"

# 定义一个函数,用于简要汇总请求的payload信息
+def payload_summary(payload):
    # 如果payload是字典,则取出其中的"messages",否则赋值为空列表
+ messages = payload.get("messages") if isinstance(payload, dict) else []
    # 如果messages是列表,则直接赋值,否则赋值为空列表
+ msg_list = messages if isinstance(messages, list) else []
    # 构建并返回汇总信息的字典
+ return {
        # 提取模型名称
+     "model": payload.get("model"),
        # 判断是否为流式输出
+     "stream": bool(payload.get("stream")),
        # 提取温度参数
+     "temperature": payload.get("temperature"),
        # 统计消息条数
+     "message_count": len(msg_list),
        # 提取最近8条消息的角色信息,并构成列表
+     "roles": [str((m or {}).get("role", "")) for m in msg_list[-8:] if isinstance(m, dict)],
        # 判断payload中是否包含tools字段
+     "has_tools": bool(payload.get("tools")),
        # 统计tools列表的长度,如果tools是列表则取长度,否则为0
+     "tool_count": len(payload.get("tools") or []) if isinstance(payload.get("tools"), list) else 0,
+ }

# 定义一个函数,用于构建 OpenAI 聊天接口所需的参数字典
+def openai_chat_kwargs(payload: dict, *, stream: bool) -> dict:
    # 构建基本参数,包括模型名、消息内容和温度
+ kwargs: dict = {
+     "model": payload["model"],                # 指定所用模型
+     "messages": payload["messages"],          # 消息列表
+     "temperature": float(payload.get("temperature", 0.3)),  # 温度参数,默认0.3
+ }
    # 如果payload中包含tools字段,则添加到参数
+ if payload.get("tools"):
+     kwargs["tools"] = payload["tools"]
    # 如果payload中包含tool_choice字段(即不为None),则添加到参数
+ if payload.get("tool_choice") is not None:
+     kwargs["tool_choice"] = payload["tool_choice"]
    # 如果启用流式输出,则添加stream参数
+ if stream:
+     kwargs["stream"] = True
    # 返回构建好的参数字典
+ return kwargs


# 定义异步生成器函数,用于流式返回 chat completion 的每个部分
+async def iter_chat_completion_events(api_base_url, api_key, payload):
    # 备注:流式一轮聊天完成后会 yield 文本 delta;最后 yield _AGENT_CHAT_ROUND_DONE(可能含完整 message 和工具调用)

    # 获取 OpenAI 服务的基础 URL 地址
+ base = openai_base_url(api_base_url)
    # 如果基础地址为空,则抛出异常
+ if not base:
+     raise RuntimeError("模型服务地址不能为空")

    # 获取请求摘要信息(合并 stream=True 到参数中)
+ summary = payload_summary({**payload, "stream": True})
    # 打印请求日志,包括 base_url 和摘要
+ logger.info("大模型聊天请求 base_url=%s 请求概要=%s", base, summary)

    # 设置对话超时时间,单位为秒
+ timeout = 90.0
    # 检查并格式化 API 密钥,去除首尾空白字符
+ key = (api_key or "").strip()

    # 使用异步上下文管理器创建 OpenAI 客户端
+ async with AsyncOpenAI(
+     api_key=key if key else "empty",  # 如果没有密钥则用"empty"
+     base_url=base,                    # 设置 API 基础地址
+     timeout=timeout,                  # 设置超时时间
+ ) as client:  
        # 根据请求参数生成 OpenAI 聊天接口所需的参数,并设置为 stream 模式
+     kwargs = openai_chat_kwargs(payload, stream=True)
        # 初始化内容部分的列表,用于保存每段返回的内容
+     content_parts = []
+     try:
            # 发起聊天流式请求,获得异步流对象
+         stream = await client.chat.completions.create(**kwargs)
+         try:
                # 异步迭代 stream,逐步获取结果块
+             async for chunk in stream:
                    # 如果没有 choices 字段,跳过该 chunk
+                 if not chunk.choices:
+                     continue
                    # 获取当前块的 delta 字段(文本增量)
+                 delta = chunk.choices[0].delta
                    # 如果 delta 为空,跳过
+                 if delta is None:
+                     continue
                    # 提取 delta 的内容片段
+                 piece = getattr(delta, "content", None) or ""
                    # 如果内容片段非空
+                 if piece:
                        # 添加到内容列表
+                     content_parts.append(piece)
                        # 通过 yield 返回本次的内容 delta
+                     yield {"type": "delta", "text": piece}
+         finally:
                # 请求结束后关闭流
+             close = getattr(stream, "close", None)
+             if close is not None:
+                 await close()
+     except APIStatusError as e:
            # 如果遇到 API 状态错误,记录日志并抛出异常
+         logger.info("大模型聊天响应 HTTP 状态码=%s", e.status_code)
+         raise
        # 日志记录:响应状态为 200 且流式请求正常
+     logger.info("大模型聊天响应 HTTP 状态码=200 stream=true")    
+     raw_content = "".join(content_parts)
+     msg: dict = {"role": "assistant", "content": raw_content if raw_content else None}
+     yield {"type": AGENT_CHAT_ROUND_DONE, "data": {"choices": [{"message": msg}]}}

# 主流程: 生成openai对话结构,并注入MCP工具,异步生成事件流(用于流式响应)
async def generate_with_tools(
    api_base_url, api_key, model_name, base_messages, mcp_services
):
    # 初始化工具列表和映射
    tools = []
    tools_mapping = {}
    # 若配置了MCP服务,获取这些服务所有工具并转换为openai标准格式
    if mcp_services:
        tools, tools_mapping = await build_openai_tools(mcp_services)
        # 记录注入工具数量到日志
        logger.info(
            "MCP工具已经注入模型中:服务数量=%s,工具数量=%s",
            len(mcp_services),
            len(tools),
        )
+   else:
        # 没有服务,不注册任何工具
+       logger.info("未向模型注册工具: 智能体未绑定 MCP 服务(绑定服务数=0)")
    # 将 base_messages 转为列表,避免原始消息对象被修改
+   messages = list(base_messages)
        # 构建请求 payload,包含模型名、消息、温度参数
+   req_payload = {"model": model_name, "messages": messages, "temperature": 0.3}
        # 初始化 data 变量为 None 用于后续接收完整一轮对话的结果
+   data = None
        # 异步遍历模型流式事件生成器,处理每个事件
+   async for ev in iter_chat_completion_events(api_base_url, api_key, req_payload):
            # 若事件类型为 AGENT_CHAT_ROUND_DONE,说明一轮对话已结束,提取数据
+       if ev.get("type") == AGENT_CHAT_ROUND_DONE:
+           data = ev["data"]
+       else:
                # 否则直接将事件产出给调用方
+           yield ev
        # 若未收到完整一轮的聊天响应,则抛出异常
+   if data is None:
+       raise RuntimeError("模型流式响应异常:未收到完整一轮结果")
        # 从 data 结构中获取助手回复的最终 message(兼容 choices 为空的情况)
+   msg = (data.get("choices") or [{}])[0].get("message") or {}
        # 提取最终回复文本,并去除首尾空格
+   final_text = str(msg.get("content") or "").strip()
        # 将最终的 assistant 回复消息加入 messages 列表
+   messages.append({"role": "assistant", "content": final_text})
        # 产出流式工具调用流程完成的事件,包含最终回复文本
+   yield {
+       "type": AGENT_CHAT_STREAM_RUN_COMPLETE,
+       "final_text": final_text,
+   }    

26.2 执行过程 #

26.2.1. 函数职责 #

generate_with_tools 是一个异步生成器(AsyncIterator[dict]),在「智能体 + 可选 MCP」场景下:

  1. 若有 MCP 服务,先异步拉取并组装 OpenAI 格式的 tools 与名字映射 tool_mapping
  2. 用 messages 副本 和模型参数组 req_payload,调用 iter_chat_completion_events 做流式 Chat Completions。
  3. 流式过程中把 delta 等中间事件原样 yield 给上层(例如 iter_agent_chat_sse → SSE)。
  4. 收到 AGENT_CHAT_ROUND_DONE 后,从 data 里取出助手最终文本,追加到本地 messages,最后 yield 一条 AGENT_CHAT_STREAM_RUN_COMPLETE(含 final_text),供上层收尾。

26.2.2. 逐步执行顺序 #

阶段 代码行为
初始化 tools = [],tool_mapping = {}
MCP 分支 mcp_services 非空:await build_openai_tools(mcp_services) → 更新 tools、tool_mapping,打日志。为空:只打「未注册工具」日志
消息副本 messages = list(base_messages),避免改调用方传入的列表
请求体 req_payload = {model, messages, temperature: 0.3} —— 当前未把 tools / tool_choice 写入 req_payload
消费流 data = None,async for ev in iter_chat_completion_events(...)
事件分发 若 ev["type"] == AGENT_CHAT_ROUND_DONE → data = ev["data"];否则 yield ev(典型为 {"type":"delta","text":...})
校验 若循环结束 data is None → RuntimeError
收尾 从 data["choices"][0]["message"] 取 content 得 final_text,messages.append({"role":"assistant","content": final_text}),再 yield {type: AGENT_CHAT_STREAM_RUN_COMPLETE, final_text}

26.2.3. 时序图(从调用方到 LLM 再回传) #

sequenceDiagram participant Caller as 上层<br/>如 iter_agent_chat_sse participant G as generate_with_tools participant B as build_openai_tools participant MCP as MCP 服务<br/>list_tools participant I as iter_chat_completion_events participant SDK as AsyncOpenAI<br/>chat.completions.create participant API as 模型 HTTP API Caller->>G: async for ... generate_with_tools(...) alt mcp_services 非空 G->>B: await build_openai_tools(mcp_services) B->>MCP: 各服务连接 + list_tools MCP-->>B: 工具元数据 B-->>G: tools, tool_mapping G->>G: logger MCP 已注入 else mcp_services 为空 G->>G: logger 未注册工具 end G->>G: messages = list(base_messages)<br/>req_payload = {model, messages, temperature} G->>I: async for iter_chat_completion_events(api..., key, req_payload) I->>SDK: AsyncOpenAI + chat.completions.create(stream=True) SDK->>API: 流式请求 loop 每个 chunk API-->>SDK: SSE/chunk SDK-->>I: chunk I-->>G: yield {type: delta, text} G-->>Caller: yield ev end I-->>G: yield {type: AGENT_CHAT_ROUND_DONE, data} G->>G: data = ev[data]<br/>解析 final_text<br/>messages.append(assistant) G-->>Caller: yield {type: AGENT_CHAT_STREAM_RUN_COMPLETE, final_text}

26.2.4. 与 iter_chat_completion_events 的配合 #

iter_chat_completion_events 负责:标准化 base_url、建客户端、流式读 delta 并 yield,最后 yield 一条 AGENT_CHAT_ROUND_DONE,里面带上拼好的 choices[0].message(当前实现里主要是 文本 content)。generate_with_tools 负责:过滤「结束事件」不向上透传、校验是否收到结束包、更新本地 messages、发出 AGENT_CHAT_STREAM_RUN_COMPLETE。

26.2.5. generate_with_tools流程图 #

flowchart TD START([进入 generate_with_tools]) --> INIT["tools=[],tool_mapping={}"] INIT --> MCP{"mcp_services 非空?"} MCP -->|是| AWAIT_BUILD["await build_openai_tools"] MCP -->|否| LOG_EMPTY["logger:未注册工具"] AWAIT_BUILD -->|异常| EX_BUILD["异常向上抛出(调用方 async for 收到)"] AWAIT_BUILD -->|成功| LOG_INJECT["logger:MCP 已注入"] LOG_EMPTY --> COPY LOG_INJECT --> COPY["messages = list(base_messages);组装 req_payload"] COPY --> DATA_NULL["data = None"] DATA_NULL --> LOOP["async for ev in iter_chat_completion_events"] LOOP --> NEXT["取下一条 ev"] NEXT -->|底层抛错| EX_ITER["异常向上抛出(不再 yield COMPLETE)"] NEXT -->|StopAsyncIteration| AFTERLOOP["循环结束"] NEXT -->|得到 ev| CHECK{"type 是否为 AGENT_CHAT_ROUND_DONE?"} CHECK -->|是| SETDATA["data = ev.data"] CHECK -->|否| YIELDEV["yield ev 给上层"] SETDATA --> LOOP YIELDEV --> LOOP AFTERLOOP --> DATANULL{"data is None?"} DATANULL -->|是| RTERR["raise RuntimeError(未收到完整一轮)"] DATANULL -->|否| PARSE["解析 final_text;append assistant"] PARSE --> YIELDCOMPLETE["yield AGENT_CHAT_STREAM_RUN_COMPLETE"] YIELDCOMPLETE --> DONE([生成器正常结束]) RTERR --> FAIL([生成器以 RuntimeError 结束]) EX_BUILD --> FAIL2([调用方收到原异常]) EX_ITER --> FAIL2

27.调用MCP工具 #

  • 百度地图

调用 MCP 工具(agent_chat 工具链原理)

在多人智能体协作或插件扩展的场景下,只让大模型聊天能力还不够,常常需要“调用工具”。本项目基于 MCP 标准 提供了强大的 工具(Tool)调用链路:大模型可流式发起工具调用,服务侧协同执行、产出流式结果,并将工具的输入输出流入对话历史,实现“AI+Tool”混合对话能力。

流程总览

  1. 上下文组装(prepare_stream_chat)
    • 校验会话、用户内容、LLM 和工具注册情况,自动写入第一条 user 消息。
    • 组装好历史消息 & MCP 服务信息,实例化 StreamChatContext。
  2. 流式推理+调用(iter_agent_chat_sse
    → generate_with_tools)
    • 真正进行流式对话和工具调用处理:监听大模型返回事件,遇到工具调用请求时,自动协同 tool 服务流式处理,并把每步 process 通过 SSE 事件流响应给前端。
  3. 工具事件摘要写入助手消息 meta
    • 每次 tool_start/tool_end 事件,自动进行配对和摘要(见 tool_correlation_key & tool_run_row_for_db)。
    • 聊天完成时将工具调用摘要数组写入 assistant 消息的 meta.mcp_tool_runs 字段。

关键代码流程

  • 会话准备:
    见 prepare_stream_chat,自动补写首条 user 消息,组装 MCP 服务并抓取历史消息。
  • 流式事件推理:
    见 iter_agent_chat_sse,核心事件流捕捉逻辑,包括工具的开始/结束配对等处理,最后写入助手消息和工具摘要。
  • 工具调用与摘要:
    • tool_correlation_key(ev):根据 call_id 或 name+tool 唯一配对一次工具调用。
    • tool_run_row_for_db(start, end):把一次 tool_start/tool_end 归纳成摘要供历史消息 meta 记载。
  • 底层异步工具调用:
    • call_tool_for_service 和 iter_tool_run 实现异步调用 MCP 工具能力,并将输入输出流式产出。
    • 支持 stdio、SSE、http 等多种 MCP 协议。

工具调用数据结构举例

助手消息 meta 里的 mcp_tool_runs 字段为数组,每项记录格式如下:

{
  "call_id": "xxxx",
  "name": "插件服务名 / 工具名",
  "tool": "工具调用方法",
  "status": "done",             // "done" 或 "error"
  "message": "错误信息或空字符串",
  "args": { ... },              // 工具调用时提交的参数对象
  "result_preview": "返回结果片段" // 工具产出的简要文本
}

这些信息便于 UI 侧渲染每次工具调用过程、结果、高亮异常等效果。

27.1. agent_chat.py #

app/routers/agent_chat.py

import logging
from urllib.parse import urljoin
from fastapi import APIRouter, Depends, HTTPException
from fastapi.responses import StreamingResponse
import httpx
import json

# 导入唯一性错误异常
from sqlalchemy.exc import IntegrityError

# 导入会话对象
from sqlalchemy.orm import Session

# 导入数据库操作
from app.repositories import (
    agent_repository,
    agent_chat_repository,
    llm_repository,
    mcp_repository,
)

from app.services.agent_chat_stream import StreamChatContext, iter_agent_chat_sse

# 获取 应用程序的数据模型
from app import schemas

# 用于获取数据库会话的依赖函数
from app.database import get_session

# 创建API路由,设置前缀和标签
router = APIRouter(tags=["agent-chat"])
# 获取当前的日志记录器
logger = logging.getLogger(__name__)
# 定义SSE(Server Sent Event)流式响应头
SSE_STREAM_HEADERS = {
    # 禁用缓存和内容转换
    "Cache-Control": "no-cache, no-transform",
    # 保持连接不断开
    "Connection": "keep-alive",
    # 关闭nginx中的响应缓存,确保数据即使发送
    "X-Accel-Buffering": "no",
}


# 根据用户的提问自动生成会话标题
def build_session_title_from_question(content: str):
    # 对传入的内容进行去除首尾空白和多余的空格,保证标题的精简  a    b c
    text = " ".join(str(content or "").strip().split())
    if not text:
        return "新对话"
    max_len = 24
    if len(text) <= max_len:
        return text
    return f"{text[:max_len]}..."


# 将数据库里的会话消息转化成OPENAI的Chat消息格式
def build_llm_messages_from_history(agent, message_rows):
    # 初始化消息列表,将智能体的系统提示词作为第一条消息
    messages = [{"role": "system", "content": str(agent.system_prompt or "").strip()}]
    # 遍历数据库里的消息列表
    for message_row in message_rows:
        # 获取消息的角色
        role = str(message_row.role or "").strip().lower()
        # 非合法角色则跳过
        if role not in {"user", "assistant", "tool"}:
            continue
        message = {"role": role, "content": message_row.content}
        messages.append(message)
    return messages


def mcp_service_dicts(session, agent):
    out = []
    for mcp_service_id in agent.mcp_service_ids or []:
        # 根据MCP服务器的ID到数据库里查对应的MCP对象
        mcp_row = mcp_repository.get_mcp_service(session, int(mcp_service_id))
        if mcp_row:
            out.append(
                {
                    "id": mcp_row.id,
                    "name": mcp_row.name,
                    "description": mcp_row.description,
                    "protocol": mcp_row.protocol,
                    "config": mcp_row.config,
                }
            )
    return out


@router.get(
    "/api/agents/{agent_id}/chat-sessions",
    response_model=list[schemas.AgentChatSessionOut],
)
def list_sessions(agent_id: int, session: Session = Depends(get_session)):
    if not agent_repository.get_agent(session, agent_id):
        raise HTTPException(status_code=404, detail="此智能体不存在")
    return agent_chat_repository.list_sessions(session, agent_id)


@router.post(
    "/api/agents/{agent_id}/chat-sessions",
    response_model=schemas.AgentChatSessionOut,
)
def create_session(
    agent_id: int,
    payload: schemas.AgentChatSessionCreate,
    session: Session = Depends(get_session),
):
    if not agent_repository.get_agent(session, agent_id):
        raise HTTPException(status_code=404, detail="此智能体不存在")
    return agent_chat_repository.create_session(session, agent_id, payload)


@router.delete("/api/agent-chat-sessions/{session_id}")
def delete_session(session_id: int, session: Session = Depends(get_session)):
    row = agent_chat_repository.get_session(session, session_id)
    if not row:
        raise HTTPException(status_code=404, detail="此智能体的会话不存在")
    agent_chat_repository.delete_session(session, row)
    return {"ok": True}


@router.get(
    "/api/agent-chat-sessions/{session_id}/messages",
    response_model=list[schemas.AgentChatMessageOut],
)
def list_messages(session_id: int, session: Session = Depends(get_session)):
    row = agent_chat_repository.get_session(session, session_id)
    if not row:
        raise HTTPException(status_code=404, detail="此会话不存在")
    return agent_chat_repository.list_messages(session, session_id)


# 为此会话添加一条消息 第一条消息就是开场白
@router.post(
    "/api/agent-chat-sessions/{session_id}/messages",
    response_model=schemas.AgentChatMessageOut,
)
def create_message(
    session_id: int,
    payload: schemas.AgentChatMessageCreate,
    session: Session = Depends(get_session),
):
    row = agent_chat_repository.get_session(session, session_id)
    if not row:
        raise HTTPException(status_code=404, detail="此会话不存在")
    return agent_chat_repository.create_message(
        session, session_id, payload.role, payload.content
    )


def prepare_stream_chat(
    session: Session, session_id: int, payload: schemas.AgentChatSendRequest
) -> StreamChatContext:
   # 将session_id转换为整数类型
    # 根据会话ID在数据库中查询对应的会话记录  models.AgentChatSession
    session_row = agent_chat_repository.get_session(session, session_id)
    if not session_row:
        raise HTTPException(status_code=404, detail="会话不存在")
    # 根据会话记录中的agent_id查询对应的智能体对象
    agent = agent_repository.get_agent(session, session_row.agent_id)
    if not agent:
        raise HTTPException(status_code=404, detail="此会话对应的智能体不存在")
    # 按照智能体查询对应的大模型提供商名称和模型名称
    llm = llm_repository.get_llm_by_provider_name(session, agent.llm_provider_name)
    if not llm:
        raise HTTPException(status_code=404, detail="找不到此智能体对应的大模型提供商")
    # 获取大语言模型的API基础地址
    llm_api_base_url = str(llm.api_base_url or "").strip()
    # 获取大语言模型API密钥
    llm_api_key = str(llm.api_key or "").strip()
    # 获取大语言模型的模型名称
    llm_model_name = str(agent.llm_model_name or "").strip()
    # 获取用户消息的内容,并去除首尾的空白
    user_content = payload.content.strip()
    if not user_content:
        raise HTTPException(status_code=404, detail="用户的消息内容不能为空")
    # 查询当前会话已经存在的历史消息列表
    existing_messages = agent_chat_repository.list_messages(session, session_id)
    # 如果说没有历史消息,并且当前会话没有标题,说明这一个提问是当前会话的第一个提问
    if not existing_messages and str(session_row.title or "").strip() in {"", "新对话"}:
        agent_chat_repository.update_session_title(
            session, session_row, build_session_title_from_question(user_content)
        )
    # 创建一条新的用户消息到当前的会话
    agent_chat_repository.create_message(session, session_id, "user", user_content)
    # 从历史消息构建LLM消息列表,准备请求LLM
    messages = build_llm_messages_from_history(
        agent, agent_chat_repository.list_messages(session, session_id)
    )
    # 获取此智能对应的MCP服务
    mcp_services = mcp_service_dicts(session, agent)
    return StreamChatContext(
        session_row=session_row,
        llm_api_base_url=llm_api_base_url,
        llm_api_key=llm_api_key,
        llm_model_name=llm_model_name,
        messages=messages,
        mcp_services=mcp_services,
    )


# 定义POST路由,支持对话消息的流式传输
@router.post("/api/agent-chat-sessions/{session_id}/messages/stream")
async def stream_message(
    session_id,
    payload: schemas.AgentChatSendRequest,
    session: Session = Depends(get_session),
):
    # 调用此方法组装流式聊天上下文对象
    ctx = prepare_stream_chat(session, session_id, payload)
    return StreamingResponse(
        iter_agent_chat_sse(session, session_id, ctx),
        media_type="text/event-stream",
        headers=SSE_STREAM_HEADERS,
    )

27.2. agent_chat.py #

app/services/agent_chat.py

# 引入异步迭代器的类型注解
from typing import AsyncIterator
# 引入openai相关异常与异步客户端
from openai import APIStatusError, AsyncOpenAI
# 引入异步上下文管理器
from contextlib import asynccontextmanager
# 引入MCP相关客户端Session和参数
from mcp import ClientSession, StdioServerParameters
# 引入MCP SSE客户端
from mcp.client.sse import sse_client
# 引入MCP stdio客户端
from mcp.client.stdio import stdio_client
# 引入MCP基于可流式HTTP的客户端
from mcp.client.streamable_http import streamable_http_client
# 引入自定义的httpx工厂方法
from app.services.mcp_httpx import mcp_httpx_client_factory
# 引入日志模块
import logging
# 导入 OpenAI 相关异常和异步客户端
from openai import APIStatusError, AsyncOpenAI
# 导入 json 库
+import json
# 引入正则表达式库
+import  re
# 初始化日志记录器,记录当前文件
logger = logging.getLogger(__file__)
# 定义一个常量,表示generate_with_tools的最后一个事件,用于路由判别流式结束(不要把这个事件直接发给客户端)
AGENT_CHAT_STREAM_RUN_COMPLETE = "AGENT_CHAT_STREAM_RUN_COMPLETE"
# 一轮对话结束
AGENT_CHAT_ROUND_DONE = "AGENT_CHAT_ROUND_DONE"
# 工具调用完成
+AGENT_CHAT_TOOL_DONE = "AGENT_CHAT_TOOL_DONE"
# 工具函数,安全访问service的属性或键(对象支持点号访问,字典支持下标访问)
def service_value(service, key, default=None):
    # 如果是字典,使用get方法,否则用getattr
    if isinstance(service, dict):
        return service.get(key, default)
    return getattr(service, key, default)


# MCP服务器连接适配器,根据协议生成对应流式读写对象
@asynccontextmanager
async def mcp_transport_streams(mcp_service):
    # 获取协议类型(如stdio、sse、http等),并转为小写
    protocol = service_value(mcp_service, "protocol", "").strip().lower()
    # 获取配置字典
    cfg = mcp_service["config"]
    # 如果协议为stdio,通过stdio_client创建进程
    if protocol == "stdio":
        # 构造StdioServerParameters对象
        stdioServerParameters = StdioServerParameters(
            command=str(cfg.get("command") or ""),
            args=[str(arg) for arg in (cfg.get("args", []))],
            env={str(k): str(v) for k, v in cfg.get("env", {}).items()},
        )
        # 使用stdio_client异步上下文获取读写流
        async with stdio_client(stdioServerParameters) as (read, write):
            yield read, write
    # 如果协议为sse(Server-Sent Events)
    elif protocol == "sse":
        # 构造请求头
        headers = {str(k): str(v) for k, v in (cfg.get("headers") or {}).items()}
        # 创建SSE客户端获取读写流
        async with sse_client(
            str(cfg.get("url") or ""),
            headers=headers,
            httpx_client_factory=mcp_httpx_client_factory,
        ) as (read, write):
            yield read, write
    # 其他协议,默认流式HTTP
    else:
        # 构造请求头
        headers = {str(k): str(v) for k, v in (cfg.get("headers") or {}).items()}
        # 获取url
        url = cfg.get("url") or ""
        # 创建streamable http客户端
        async with mcp_httpx_client_factory(headers=headers) as http_client:
            async with streamable_http_client(url, http_client=http_client) as (
                read,
                write,
                _,
            ):
                yield read, write


# 获取指定MCP服务器对应的全部工具列表
async def list_tools_for_service(mcp_service):
    # 使用异步上下文连接MCP服务,获取读写流
    async with mcp_transport_streams(mcp_service) as (read, write):
        # 创建ClientSession进行会话
        async with ClientSession(read, write) as session:
            # 初始化会话
            await session.initialize()
            # 异步获取工具列表
            tools_result = await session.list_tools()
            # 从结果中取出原始工具列表
            raw_tools = getattr(tools_result, "tools", None) or []
            out = []
            # 遍历所有工具,构造成统一格式
            for tool in raw_tools:
                out.append(
                    {
                        "name": str(getattr(tool, "name", "") or ""),
                        "description": str(getattr(tool, "description", "") or ""),
                        "input_schema": getattr(tool, "inputSchema", None)
                        or getattr(tool, "input_schema", None)
                        or {},
                    }
                )
            # 返回统一后的工具列表
            return out


# 工具名称归一化(
def normalize_tool_name(service_name, tool_name):
+ raw_s = str(service_name or "").strip().lower()
+ raw_t = str(tool_name or "").strip().lower()
+ s = re.sub(r"[^a-z0-9_-]+", "_", raw_s).strip("_")
+ t = re.sub(r"[^a-z0-9_-]+", "_", raw_t).strip("_")
+ if not s:
+     s = "svc"
+ if not t:
+     t = "tool"
+ merged = f"{s}__{t}"
+ merged = re.sub(r"_+", "_", merged)
+ return merged[:64]


# 统一工具的输入schema格式(需为字典,否则给默认对象结构)
def normalize_tool_schema(schema):
    if isinstance(schema, dict):
        return schema
    return {"type": "object", "properties": {}}


# 构建OpenAI标准格式的工具列表及其映射(openai函数格式)
async def build_openai_tools(mcp_services):
    # 初始化工具列表和映射字典
    tools = []
    tools_mapping = {}
    # 遍历所有MCP服务,收集其暴露的工具
    for mcp_service in mcp_services:
        # 获取服务名,如果没有name则用service_id
        mcp_service_name = (
            str(service_value(mcp_service, "name", ""))
            or f"service_{service_value(mcp_service, "id", "")}"
        )
        # 获取当前服务实际暴露出的所有工具
        for tool in await list_tools_for_service(mcp_service):
            # 获取原始工具名称,去除空白
            original_name = str(tool.get("name", "") or "").strip()
            # 如果没有工具名,跳过
            if not original_name:
                continue
            # 用于暴露给大模型调用的标准工具名称
            exposed_name = normalize_tool_name(mcp_service_name, original_name)
            # 已收录该工具,则跳过
            if exposed_name in tools_mapping:
                continue
            # 记录该工具的服务、工具名称、服务名等信息
            tools_mapping[exposed_name] = {
                "mcp_service": mcp_service,  # MCP服务对象
                "tool_name": original_name,  # MCP原始工具名
                "mcp_service_name": mcp_service_name,  # MCP服务名
            }
            # 按OpenAI函数格式追加工具描述
            tools.append(
                {
                    "type": "function",
                    "function": {
                        "name": exposed_name,  # 函数名称
                        "description": str(
                            tool.get("description")
                            or f"{mcp_service_name}/{original_name}"
                        ),  # 函数描述
                        # 工具的输入schema作为openai函数的参数定义
                        "parameters": normalize_tool_schema(
                            tool.get("input_schema")
                        ),  # 参数定义
                    },
                }
            )
    # 返回工具列表和工具名映射
    return tools, tools_mapping


# 定义一个函数,将给定的 API 基础地址标准化为以 /v1 结尾,以兼容 OpenAI 官方 SDK(不包含 chat/completions)
def openai_base_url(api_base_url):
    # 将输入的 api_base_url 转为字符串,去除首尾空白并移除末尾斜杠
  base = str(api_base_url or "").strip().rstrip("/")
    # 如果处理后的 base 为空,则返回空字符串
  if not base:
      return ""
    # 将 base 字符串转换为小写形式
  lower = base.lower()
    # 如果 base 已经以 /v1 结尾,则直接返回
  if lower.endswith("/v1"):
      return base
    # 否则在末尾补上 /v1 并返回
  return f"{base}/v1"

# 定义一个函数,用于简要汇总请求的payload信息
def payload_summary(payload):
    # 如果payload是字典,则取出其中的"messages",否则赋值为空列表
  messages = payload.get("messages") if isinstance(payload, dict) else []
    # 如果messages是列表,则直接赋值,否则赋值为空列表
  msg_list = messages if isinstance(messages, list) else []
    # 构建并返回汇总信息的字典
  return {
        # 提取模型名称
      "model": payload.get("model"),
        # 判断是否为流式输出
      "stream": bool(payload.get("stream")),
        # 提取温度参数
      "temperature": payload.get("temperature"),
        # 统计消息条数
      "message_count": len(msg_list),
        # 提取最近8条消息的角色信息,并构成列表
      "roles": [str((m or {}).get("role", "")) for m in msg_list[-8:] if isinstance(m, dict)],
        # 判断payload中是否包含tools字段
      "has_tools": bool(payload.get("tools")),
        # 统计tools列表的长度,如果tools是列表则取长度,否则为0
      "tool_count": len(payload.get("tools") or []) if isinstance(payload.get("tools"), list) else 0,
  }

# 定义一个函数,用于构建 OpenAI 聊天接口所需的参数字典
def openai_chat_kwargs(payload: dict, *, stream: bool) -> dict:
    # 构建基本参数,包括模型名、消息内容和温度
  kwargs: dict = {
      "model": payload["model"],                # 指定所用模型
      "messages": payload["messages"],          # 消息列表
      "temperature": float(payload.get("temperature", 0.3)),  # 温度参数,默认0.3
  }
    # 如果payload中包含tools字段,则添加到参数
  if payload.get("tools"):
      kwargs["tools"] = payload["tools"]
    # 如果payload中包含tool_choice字段(即不为None),则添加到参数
  if payload.get("tool_choice") is not None:
      kwargs["tool_choice"] = payload["tool_choice"]
    # 如果启用流式输出,则添加stream参数
  if stream:
      kwargs["stream"] = True
    # 返回构建好的参数字典
  return kwargs


# 定义一个函数,将传入的参数字符串解析为字典
+def tool_call_args_dict(args_text: str) -> dict:
    # 尝试解析JSON字符串
+ try:
        # 如果参数文本存在,则尝试加载为JSON对象,否则返回空字典
+     args = json.loads(args_text) if args_text else {}
        # 成功加载后,判断结果是否为字典类型,如果是则返回,否则返回空字典
+     return args if isinstance(args, dict) else {}
    # 捕获JSON解析错误,出现异常时返回空字典
+ except json.JSONDecodeError:
+     return {}

# 定义一个函数,用于将输入文本进行简要预览,超出指定长度会被截断并添加省略号
+def result_preview(text, limit=260):
    # 将输入文本转换为字符串,并按空格拆分后再重组,去除多余空白
+ clean = " ".join(str(text or "").split())
    # 如果清理后的文本长度小于等于限制,则直接返回
+ if len(clean) <= limit:
+     return clean
    # 否则只返回前 limit 个字符,并加上省略号
+ return f"{clean[:limit]}..."

# 定义异步函数,用于调用某个服务的指定工具,并传入参数
+async def call_tool_for_service(service, tool_name, args):
    # 通过 mcp_transport_streams 获取与服务的通信读写流(支持多种协议)
+ async with mcp_transport_streams(service) as (read, write):
        # 利用读写流创建一个客户端会话
+     async with ClientSession(read, write) as session:
            # 初始化会话(比如握手、鉴权等前置步骤)
+         await session.initialize()
            # 调用指定工具方法,并传入参数,获取结果
+         result = await session.call_tool(tool_name, args)
    # 定义 parts 列表,用于收集结果中的文本片段
+ parts = []
    # 遍历结果对象中的 content 属性(如果没有则用空列表)
+ for item in (getattr(result, "content", None) or []):
        # 获取每个 item 的 text 属性并转为字符串,去除首尾空白
+     txt = str(getattr(item, "text", "") or "").strip()
        # 如果文本不为空,则加入 parts 列表
+     if txt:
+         parts.append(txt)
    # 如果 parts 列表非空,说明有返回内容,将多段文本用换行拼接后返回
+ if parts:
+     return "\n".join(parts)
    # 如果没有可用内容,返回默认消息提示
+ return "工具调用成功,但未返回文本内容。"

# 定义一个异步生成器函数,用于执行工具的运行流程
+async def iter_tool_run(mapping, call_id, args):
    # 格式化 display 字符串,显示为“服务名 / 工具名”
+ display = f"{mapping['mcp_service_name']} / {mapping['tool_name']}"
    # 获取工具名
+ tool = mapping["tool_name"]
    # 产出工具开始事件,包含调用ID、工具显示名、工具名和参数
+ yield {"type": "tool_start", "call_id": call_id, "name": display, "tool": tool, "args": args}
+ try:
        # 调用实际的工具服务,获取返回的文本结果
+     result_text = await call_tool_for_service(mapping["mcp_service"], mapping["tool_name"], args)
        # 工具调用成功,产出工具结束事件,包含预览文本
+     yield {
+         "type": "tool_end",#tool_end 事件类型,表示工具调用结束
+         "call_id": call_id,#调用ID,用于标识工具调用的唯一性    
+         "name": display,#工具显示名,用于在 UI 中展示
+         "tool": tool,#工具名,用于标识工具的唯一性
+         "ok": True,#是否成功,用于标识工具调用是否成功
+         "result_preview": result_preview(result_text),#预览文本,用于在 UI 中展示
+     }
+ except Exception as e:  # noqa: BLE001
        # 捕捉异常,如果工具调用失败,格式化返回的错误信息
+     result_text = f"工具调用失败: {e}"
        # 工具调用失败,产出工具结束事件,并携带错误信息和预览
+     yield {
+         "type": "tool_end",#tool_end 事件类型,表示工具调用结束
+         "call_id": call_id,#调用ID,用于标识工具调用的唯一性    
+         "name": display,#工具显示名,用于在 UI 中展示
+         "tool": tool,#工具名,用于标识工具的唯一性
+         "ok": False,#是否成功,用于标识工具调用是否成功
+         "message": str(e),#错误信息,用于在 UI 中展示
+         "result_preview": result_preview(result_text),#预览文本,用于在 UI 中展示
+     }
    # 工具执行流程完成,产出最终工具执行完成事件,包含实际返回文本
+ yield {"type": AGENT_CHAT_TOOL_DONE, "result_text": result_text}

# 合并流式工具调用信息,将新的一批调用片段合并到已存在的索引字典中
+def merge_stream_tool_calls(tool_calls_by_index: dict[int, dict], chunk_tool_calls) -> None:
    # 遍历本次分片中的每个工具调用对象,如果没有则为空列表
+ for tc in chunk_tool_calls or []:
        # 获取当前调用的索引 index,如果 index 不存在则默认为 0
+     ix = int(tc.index) if tc.index is not None else 0
        # 如果该索引还没有 entry,先初始化一个默认结构
+     if ix not in tool_calls_by_index:
            # 初始化一个默认结构
+         tool_calls_by_index[ix] = {
+             "id": "",#工具调用 ID
+             "type": "function",#工具调用类型
+             "function": {"name": "", "arguments": ""},#工具调用函数
+         }
        # 取得当前索引对应的槽(已合并的工具调用信息)
+     slot = tool_calls_by_index[ix]
        # 如果 tc 有 id 属性,追加到槽中
+     if getattr(tc, "id", None):
+         slot["id"] = tc.id
        # 如果 tc 有 type 属性,追加到槽中
+     if getattr(tc, "type", None):
+         slot["type"] = tc.type
        # 获取 tc 的 function 属性(可能为 None)
+     fn = getattr(tc, "function", None)
+     if fn is not None:
            # 如果 function 有 name 属性,追加到 function name 字段
+         if getattr(fn, "name", None):
+             slot["function"]["name"] += fn.name
            # 如果 function 有 arguments 属性,追加到 function arguments 字段
+         if getattr(fn, "arguments", None):
+             slot["function"]["arguments"] += fn.arguments


# 定义异步生成器函数,用于流式返回 chat completion 的每个部分
async def iter_chat_completion_events(api_base_url, api_key, payload):
    # 备注:流式一轮聊天完成后会 yield 文本 delta;最后 yield _AGENT_CHAT_ROUND_DONE(可能含完整 message 和工具调用)

    # 获取 OpenAI 服务的基础 URL 地址
  base = openai_base_url(api_base_url)
    # 如果基础地址为空,则抛出异常
  if not base:
      raise RuntimeError("模型服务地址不能为空")

    # 获取请求摘要信息(合并 stream=True 到参数中)
  summary = payload_summary({**payload, "stream": True})
    # 打印请求日志,包括 base_url 和摘要
  logger.info("大模型聊天请求 base_url=%s 请求概要=%s", base, summary)

    # 设置对话超时时间,单位为秒
  timeout = 90.0
    # 检查并格式化 API 密钥,去除首尾空白字符
  key = (api_key or "").strip()

    # 使用异步上下文管理器创建 OpenAI 客户端
  async with AsyncOpenAI(
      api_key=key if key else "empty",  # 如果没有密钥则用"empty"
      base_url=base,                    # 设置 API 基础地址
      timeout=timeout,                  # 设置超时时间
  ) as client:  
        # 根据请求参数生成 OpenAI 聊天接口所需的参数,并设置为 stream 模式
      kwargs = openai_chat_kwargs(payload, stream=True)
        # 初始化内容部分的列表,用于保存每段返回的内容
      content_parts = []
       # 初始化工具调用索引字典,用于合并流式工具调用信息
+     tool_calls_by_index = {}
      try:
            # 发起聊天流式请求,获得异步流对象
          stream = await client.chat.completions.create(**kwargs)
          try:
                # 异步迭代 stream,逐步获取结果块
              async for chunk in stream:
                    # 如果没有 choices 字段,跳过该 chunk
                  if not chunk.choices:
                      continue
                    # 获取当前块的 delta 字段(文本增量)
                  delta = chunk.choices[0].delta
                    # 如果 delta 为空,跳过
                  if delta is None:
                      continue
                    # 提取 delta 的内容片段
                  piece = getattr(delta, "content", None) or ""
                    # 如果内容片段非空
                  if piece:
                        # 添加到内容列表
                      content_parts.append(piece)
                        # 通过 yield 返回本次的内容 delta
                      yield {"type": "delta", "text": piece}
                  # 提取 delta 的 tool_calls 片段
+                 tool_part = getattr(delta, "tool_calls", None)
                    # 如果 tool_part 非空,合并到 tool_calls_by_index
+                 if tool_part:
+                     merge_stream_tool_calls(tool_calls_by_index, tool_part)     
          finally:
                # 请求结束后关闭流
              close = getattr(stream, "close", None)
              if close is not None:
                  await close()
      except APIStatusError as e:
            # 如果遇到 API 状态错误,记录日志并抛出异常
          logger.info("大模型聊天响应 HTTP 状态码=%s", e.status_code)
          raise
        # 日志记录:响应状态为 200 且流式请求正常
      logger.info("大模型聊天响应 HTTP 状态码=200 stream=true")    
      raw_content = "".join(content_parts)
      msg: dict = {"role": "assistant", "content": raw_content if raw_content else None}
      # 如果工具调用索引字典非空,将工具调用信息加入消息对象
+     if tool_calls_by_index:
          # 将工具调用信息按索引排序后加入消息对象
+         msg["tool_calls"] = [tool_calls_by_index[i] for i in sorted(tool_calls_by_index)]
      yield {"type": AGENT_CHAT_ROUND_DONE, "data": {"choices": [{"message": msg}]}}

# 主流程: 生成openai对话结构,并注入MCP工具,异步生成事件流(用于流式响应)
async def generate_with_tools(
    api_base_url, api_key, model_name, base_messages, mcp_services
):
    # 初始化工具列表和映射
    tools = []
    tools_mapping = {}
    # 若配置了MCP服务,获取这些服务所有工具并转换为openai标准格式
    if mcp_services:
        tools, tools_mapping = await build_openai_tools(mcp_services)
        # 记录注入工具数量到日志
        logger.info(
            "MCP工具已经注入模型中:服务数量=%s,工具数量=%s",
            len(mcp_services),
            len(tools),
        )
    else:
        # 没有服务,不注册任何工具
        logger.info("未向模型注册工具: 智能体未绑定 MCP 服务(绑定服务数=0)")
    # 将 base_messages 转为列表,避免原始消息对象被修改
    messages = list(base_messages)
     # 最多循环 10 次(防止死循环或多轮嵌套工具调用)
+   for _ in range(10):
        # 构建请求 payload,包含模型名,当前消息历史,及温度参数
+     req_payload = {"model": model_name, "messages": messages, "temperature": 0.3}
        # 如果 tools 非空,将 tools 注入 payload
+     if tools:
            # 把 tools 信息加到 payload,告诉大模型可用的工具
+         req_payload["tools"] = tools
            # 设置 tool_choice 字段为 "auto",允许模型自主决定是否调用工具
+         req_payload["tool_choice"] = "auto"
        # 初始化 data 为 None,用于存放最终的轮次回复数据
+     data = None
        # 异步遍历大模型流事件
+     async for ev in iter_chat_completion_events(api_base_url, api_key, req_payload):
            # 如果事件类型是轮次完成(即一轮对话回复完成)
+         if ev.get("type") == AGENT_CHAT_ROUND_DONE:
              # 把本轮数据存下来
+             data = ev["data"]
+         else:
              # 其余事件(delta、思考等)直接产出给调用方(前端/上层处理)
+             yield ev
          # 如果 data 一直没拿到,可能 LLM 没有正常返回,抛出异常提示
+     if data is None:
+         raise RuntimeError("模型流式响应异常:未收到完整一轮结果")
      # 从 data 中解析出 message(通常在 choices[0].message)
+     msg = (data.get("choices") or [{}])[0].get("message") or {}
      # 获取本轮回复是否包含 tool_calls(即是否要用到工具)
+     tool_calls = msg.get("tool_calls") or []
      # 如果没有包含 tool_calls,则本次是普通文本回复,不需要多余的工具调用环节
+     if not tool_calls:
         # 提取最终文本(去首尾空白)
+       final_text = str(msg.get("content") or "").strip()
        # 把 assistant 的回复加入消息堆栈
+       messages.append({"role": "assistant", "content": final_text})
        # 产出流式完成事件,给外部标记完成
+       yield {
+           "type": AGENT_CHAT_STREAM_RUN_COMPLETE,
+           "final_text": final_text,
+       }
        # 结束主流程
+       return
      # 如果有 tool_calls,先把请求和 tool_calls 加入消息序列
+     messages.append({"role": "assistant", "content": msg.get("content"), "tool_calls": tool_calls})
      # 遍历所有本轮要调用的工具
+     for call in tool_calls:
        # 提取函数描述信息(function name/参数等)
+       fn = call.get("function") or {}
            # 获取暴露给 LLM 的工具名(exposed_name)
+       exposed_name = str(fn.get("name") or "").strip()
            # 获取工具的参数字符串(一般是 JSON)
+       args_text = str(fn.get("arguments") or "{}")
            # 获取本次调用的唯一 id
+       call_id = str(call.get("id") or "")
            # 把参数字符串解析成 dict
+       args = tool_call_args_dict(args_text)
            # 查找 exposed_name 映射到的具体工具实现
+       mapping = tools_mapping.get(exposed_name)
            # 如果映射不到,实现不存在,直接产出失败事件
+       if not mapping:
                # 设置失败说明文字
+           result_text = f"未找到可调用工具: {exposed_name}"
                # 产出工具调用开始事件
+           yield {
+               "type": "tool_start",#tool_start 事件类型,表示工具调用开始
+               "call_id": call_id,#调用ID,用于标识工具调用的唯一性    
+               "name": exposed_name,#工具显示名,用于在 UI 中展示
+               "tool": exposed_name,#工具名,用于标识工具的唯一性
+               "args": args,#工具参数,用于标识工具的参数
+           }
                # 产出工具调用失败事件,并附说明
+           yield {
+               "type": "tool_end",#tool_end 事件类型,表示工具调用结束
+               "call_id": call_id,#调用ID,用于标识工具调用的唯一性    
+               "name": exposed_name,#工具显示名,用于在 UI 中展示
+               "tool": exposed_name,#工具名,用于标识工具的唯一性
+               "ok": False,#是否成功,用于标识工具调用是否成功
+               "message": result_text,#错误信息,用于在 UI 中展示
+               "result_preview": result_preview(result_text),#预览文本,用于在 UI 中展示
+           }
+       else:
            # 准备结果文本占位
+           result_text = None
            # 实际调用工具(stream/异步过程)
+           async for ev in iter_tool_run(mapping, call_id, args):
+               if ev.get("type") == AGENT_CHAT_TOOL_DONE:
                    # 工具流程走完,保存最终文本
+                   result_text = ev["result_text"]
+               else:
                        # delta 或其它事件直接产出
+                   yield ev
            # 工具调用结果补入消息序列,供下一轮决策参考
+       messages.append({"role": "tool", "tool_call_id": call_id, "content": result_text})   
    # 初始化 data 变量为 None 用于后续接收完整一轮对话的结果
    data = None
    # 异步遍历模型流式事件生成器,处理每个事件
    async for ev in iter_chat_completion_events(api_base_url, api_key, req_payload):
            # 若事件类型为 AGENT_CHAT_ROUND_DONE,说明一轮对话已结束,提取数据
        if ev.get("type") == AGENT_CHAT_ROUND_DONE:
            data = ev["data"]
        else:
                # 否则直接将事件产出给调用方
            yield ev
        # 若未收到完整一轮的聊天响应,则抛出异常
    if data is None:
        raise RuntimeError("模型流式响应异常:未收到完整一轮结果")
        # 从 data 结构中获取助手回复的最终 message(兼容 choices 为空的情况)
    msg = (data.get("choices") or [{}])[0].get("message") or {}
        # 提取最终回复文本,并去除首尾空格
    final_text = str(msg.get("content") or "").strip()
        # 将最终的 assistant 回复消息加入 messages 列表
    messages.append({"role": "assistant", "content": final_text})
        # 产出流式工具调用流程完成的事件,包含最终回复文本
    yield {
        "type": AGENT_CHAT_STREAM_RUN_COMPLETE,
        "final_text": final_text,
    }    

27.3. agent_chat_stream.py #

app/services/agent_chat_stream.py

# 引入命名元组类型,用于定义结构化数据类型
from typing import NamedTuple
# 引入数据模型,通常是ORM定义的数据库模型
from app import models
# 引入SQLAlchemy的数据库会话类型
from sqlalchemy.orm import Session
# 引入日志模块,用于记录日志信息
import logging
# 引入JSON模块,用于数据序列化
import json
# 引入会话消息存储/更新的仓库方法
from app.repositories import agent_chat_repository
# 引入生成带工具对话流的核心方法和流式完成标志常量
from app.services.agent_chat import generate_with_tools, AGENT_CHAT_STREAM_RUN_COMPLETE

# 创建logger实例,记录本模块的日志
logger = logging.getLogger(__name__)

# 定义一个将数据字典格式化为SSE(Server-Sent Events)协议字符串的函数
def sse(data: dict) -> str:
    # 将字典转换为JSON字符串,然后封装成SSE格式,每条消息后追加\n\n
    return f"data: {json.dumps(data,ensure_ascii=False,default=str)}\n\n"

# 定义用于流式聊天上下文的命名元组数据结构
class StreamChatContext(NamedTuple):
    # 当前聊天会话的数据库行对象
    session_row: models.AgentChatSession
    # LLM API的基础URL地址
    llm_api_base_url: str
    # LLM的API Key
    llm_api_key: str
    # LLM的模型名称
    llm_model_name: str
    # 聊天消息历史列表
    messages: list
    # 相关的MCP服务列表
    mcp_services: list


# 定义一个函数,根据事件字典生成用于工具调用相关性的唯一 key
+def tool_correlation_key(ev: dict) -> str:
+  """与前端 toolCallRowKey 一致,用于配对 tool_start / tool_end。"""
    # 尝试从事件字典中获取 call_id
+  cid = ev.get("call_id")
    # 如果 call_id 非空且去除空白后不为"",则直接返回字符串形式的 call_id 作为 key
+  if cid is not None and str(cid).strip() != "":
+      return str(cid)
    # 否则,用 name 和 tool 两个字段拼接为 key,格式为 "name#tool"
+  return f"{ev.get('name') or ''}#{ev.get('tool') or ''}"

# 定义一个函数,用于将工具调用的开始和结束事件合成一条用于数据库存储的摘要
+def tool_run_row_for_db(tool_start: dict, tool_end: dict) -> dict:
    # """合并一次工具调用的起止事件,写入 assistant 消息的 meta.mcp_tool_runs。"""
    # 获取工具调用参数,如果 tool_start 中 "args" 字段是字典就用它,否则用空字典
+  args = tool_start.get("args") if isinstance(tool_start.get("args"), dict) else {}
    # 返回一个字典,汇总这次工具调用的关键信息
+  return {
        # 工具调用的唯一标识,将 call_id 转为字符串(为 None 时转空字符串)
+      "call_id": str(tool_end.get("call_id") or ""),
        # 工具显示名
+      "name": tool_end.get("name"),
        # 工具的唯一名称
+      "tool": tool_end.get("tool"),
        # 工具调用状态标志,ok 为 True 则为 "done",否则为 "error"
+      "status": "done" if tool_end.get("ok") else "error",
        # 若有 message 字段则转为字符串,否则为 ""
+      "message": str(tool_end.get("message") or ""),
        # 工具调用时传递的参数
+      "args": args,
        # 工具调用的结果简要预览,若不存在则为 ""
+      "result_preview": str(tool_end.get("result_preview") or ""),
+  }

# 定义异步生成器函数,用于流式发送聊天SSE数据
async def iter_agent_chat_sse(
    session: Session, session_id: int, ctx: StreamChatContext
):
    # 记录流式对话的日志
    logger.info(
        "开始流式对话 session_id=%s,模型名称=%s", session_id, ctx.llm_model_name
    )
    # 发送表示流式对话开始的SSE事件
    yield sse({"type": "start"})
    try:
        # 定义一个变量用于存储工具调用的开始事件
+       pending_tool = {}
        # 定义一个变量用于存储工具调用的摘要
+       mcp_tool_runs = []
        # 初始化最终AI回复内容的变量
        final_reply = "AI的回答"
        # 异步迭代生成工具事件,每个事件为response片段
        async for event in generate_with_tools(
            ctx.llm_api_base_url,
            ctx.llm_api_key,
            ctx.llm_model_name,
            ctx.messages,
            ctx.mcp_services,
        ):
            # 从事件中获取"type"字段,判断事件类型
            type = event.get("type")
            # 如果遍历到AGENT_CHAT_STREAM_RUN_COMPLETE类型,表示最终回复内容
            if type == AGENT_CHAT_STREAM_RUN_COMPLETE:
                # 记录最终回复内容
                final_reply = event.get("final_text") or ""
                # 跳过当前事件,不发送给客户端
                continue
             # 如果事件类型是工具开始,则将事件存入 pending_tool,等待配对结束事件
+           elif event == "tool_start":
+            pending_tool[tool_correlation_key(event)] = event
            # 如果事件类型是工具结束,则从 pending_tool 匹配拿出开始事件,并生成工具调用摘要
+           elif event == "tool_end":
              # 获取工具调用的相关性唯一 key
+            key = tool_correlation_key(event)
              # 从 pending_tool 匹配拿出开始事件
+            start = pending_tool.pop(key, {})
              # 生成工具调用摘要
+            mcp_tool_runs.append(tool_run_row_for_db(start, event))  
            # 其余事件都以SSE格式发送到客户端
            yield sse(event)
        # 如果有工具调用摘要则写入 meta,否则为空字典
+       meta = {"mcp_tool_runs": mcp_tool_runs} if mcp_tool_runs else {}     
        # 聊天完成后将AI助手回复消息保存到数据库
        agent_chat_repository.create_message(
+          session, session_id, "assistant", final_reply,  meta=meta
        )
        # 更新当前会话的活跃时间/状态
        agent_chat_repository.touch_session(session, ctx.session_row)
        # 记录结束日志,包括AI回复的字数
        logger.info(
            "流式对话结束 session_id=%s 助手回复的字数=%s", session_id, len(final_reply)
        )
        # 发送“完成”事件到客户端表示流式对话已结束
        yield sse({"type": "done"})
    # 捕获所有异常,保证异常时也给客户端返回错误消息
    except Exception as e:
        # 记录异常日志
        logger.exception("流式对话生成失败session_id=%s", session_id)
        # 发送错误事件到客户端,附带错误信息
        yield sse({"type": "error", "message": str(e)})

27.3 执行流程 #

27.3.1. 总体结构 #

generate_with_tools 是异步生成器:先(可选)从 MCP 拉工具并注入请求,再在最多 10 轮循环里反复:

  1. 用当前 messages 调 iter_chat_completion_events(流式 Chat Completions);
  2. 把过程中的 delta 等事件原样 yield 给上层;
  3. 收到 AGENT_CHAT_ROUND_DONE 后,看助手 message 里有没有 tool_calls:
    • 没有:当作最终用户可见回复,追加 assistant、yield AGENT_CHAT_STREAM_RUN_COMPLETE,return;
    • 有:把带 tool_calls 的 assistant 消息写入历史,逐个执行工具,把 tool 角色消息写回历史,再进入下一轮 for。
  4. 若 10 轮内始终没有「无 tool_calls 的收尾」,循环结束后会再走一次不带 tools 的最终补全(560–585 行),再 yield 完成事件。

常量含义:AGENT_CHAT_ROUND_DONE、AGENT_CHAT_STREAM_RUN_COMPLETE、AGENT_CHAT_TOOL_DONE 见文件顶部 25–30 行附近。

27.3.2. 准备阶段(循环前) #

  • mcp_services 非空:await build_openai_tools(mcp_services) → 得到 OpenAI 的 tools 列表和 tool_mapping(exposed_name → service / tool_name / service_name),并打日志。
  • messages = list(base_messages),避免改调用方传入的列表。

27.3.3. 单轮 LLM 流式:iter_chat_completion_events 与工具在流里的合并 #

每轮 req_payload 含 model、messages、temperature;若 tools 非空,还会加 tools 和 tool_choice: "auto"。

iter_chat_completion_events 内(约 361–438 行):

  • 流式读每个 chunk 的 delta.content → 拼进 content_parts,并 yield {"type":"delta","text":...};
  • 若有 delta.tool_calls,用 merge_stream_tool_calls 按 index 把碎片拼成完整 tool_calls(id、function.name、function.arguments 都是流式累加);
  • 流结束后组装 msg:content +(若有)tool_calls 列表,再 yield AGENT_CHAT_ROUND_DONE。

因此:工具是否被调用首先体现为最后一帧里的 message.tool_calls,而不是单独的 SSE 类型。

27.3.4. 工具调用环节 #

当 tool_calls 非空时:

  1. 先把本轮助手消息写入历史(含 content 与 tool_calls),与 OpenAI 多轮格式一致。
  2. for call in tool_calls,对每个调用:

    • 从 call["function"] 取 exposed_name(与 build_openai_tools 里注册的 function.name 一致)、arguments 字符串、call_id;
    • args = tool_call_args_dict(args_text):把 JSON 参数字符串解析成 dict,解析失败则为 {}。
    • mapping = tool_mapping.get(exposed_name):

    A. 映射不存在

    • 不连 MCP;依次 yield tool_start、tool_end(ok=False);
    • result_text = f"未找到可调用工具: {exposed_name}";
    • 注意:这里没有走 iter_tool_run,因此也没有 AGENT_CHAT_TOOL_DONE 事件。

    B. 映射存在

    • async for ev in iter_tool_run(mapping, call_id, args):
      • tool_start(展示名 service_name / tool_name、真实 tool、参数);
      • await call_tool_for_service → MCP session.call_tool,结果拼成文本;
      • tool_end(ok / result_preview / 失败时的 message);
      • 最后 AGENT_CHAT_TOOL_DONE,result_text 给 generate_with_tools 用来 messages.append({"role":"tool", "tool_call_id": call_id, "content": result_text})。
    • 中间非 AGENT_CHAT_TOOL_DONE 的事件 yield 给上层(前端可展示进度)。
  3. 所有 tool_calls 处理完后,不 return,下一轮 for _ in range(10) 会用更新后的 messages(含 assistant+tool_calls 与多条 tool 结果)再次请求模型,让模型基于工具输出继续生成或再次调用工具。

call_tool_for_service(138–161 行):mcp_transport_streams → ClientSession → initialize → call_tool(tool_name, args),从返回的 content[].text 拼成字符串。

27.3.5. 10 轮用尽后的「最终补全」 #

若 10 轮内模型始终还带 tool_calls,循环正常结束,再发起一次 iter_chat_completion_events,payload 只有 model/messages/temperature(不再带 tools),迫使模型在已有上下文中给出纯文本收尾;最后再 yield AGENT_CHAT_STREAM_RUN_COMPLETE。

27.3.6. 时序图(工具调用一轮) #

sequenceDiagram participant GW as generate_with_tools participant ICE as iter_chat_completion_events participant API as 模型 API participant ITR as iter_tool_run participant MCP as MCP call_tool GW->>ICE: async for 流式请求含 tools loop 每个 chunk ICE->>API: stream read API-->>ICE: delta ICE-->>GW: delta 事件 GW-->>GW: yield 给上层 end ICE->>ICE: 合并 tool_calls ICE-->>GW: AGENT_CHAT_ROUND_DONE GW->>GW: 解析 message.tool_calls alt 无 tool_calls GW->>GW: append assistant GW-->>GW: yield STREAM_RUN_COMPLETE else 有 tool_calls GW->>GW: append assistant 含 tool_calls loop 每个 call alt mapping 缺失 GW-->>GW: yield tool_start GW-->>GW: yield tool_end 失败 GW->>GW: result_text 错误说明 else mapping 存在 GW->>ITR: async for iter_tool_run ITR-->>GW: tool_start GW-->>GW: yield 给上层 ITR->>MCP: call_tool MCP-->>ITR: 文本结果 ITR-->>GW: tool_end GW-->>GW: yield 给上层 ITR-->>GW: AGENT_CHAT_TOOL_DONE GW->>GW: result_text end GW->>GW: append tool 消息 end GW->>ICE: 下一轮请求 新 messages end

27.3.7. merge_stream_tool_calls 与 chunk 的关系 #

下面专门描述:流式 chunk → delta.tool_calls 片段 → merge_stream_tool_calls 如何写入 tool_calls_by_index → 流结束后如何变成完整 message.tool_calls。逻辑与当前 iter_chat_completion_events + merge_stream_tool_calls 一致。

子图:merge_stream_tool_calls 与 chunk 的关系

要点

  • 模型在流式响应里,工具调用往往拆成多个 chunk:某次只带 index + id,某次只追加 function.name 的几个字符,某次只追加 function.arguments 的一截 JSON 字符串。
  • merge_stream_tool_calls 按 tc.index 分槽位,对 name / arguments 做字符串拼接(+=),多次 chunk 调用同一函数会把碎片累加成完整工具调用描述。
  • iter_chat_completion_events 在流循环内不 yield 工具片段;只在流结束、拼好 tool_calls_by_index 后,随 AGENT_CHAT_ROUND_DONE 里的 message.tool_calls 一次性交给下游。
sequenceDiagram participant API as 模型流式 API participant ICE as iter_chat_completion_events participant Merge as merge_stream_tool_calls participant Dict as tool_calls_by_index Note over ICE,Dict: 每来一个 chunk 可能同时含 content 与 tool_calls 片段 API-->>ICE: chunk 1 delta.tool_calls 片段 A ICE->>Merge: merge_stream_tool_calls(Dict, 片段 A) Merge->>Dict: 按 index 建槽或更新 id type name arguments ICE-->>ICE: 若有 content 则 yield delta API-->>ICE: chunk 2 仅 delta.content ICE-->>ICE: yield delta 文本 API-->>ICE: chunk 3 delta.tool_calls 片段 B ICE->>Merge: merge_stream_tool_calls(Dict, 片段 B) Merge->>Dict: 同 index 下 name 或 arguments 追加拼接 API-->>ICE: chunk N 最后一片 tool 碎片 ICE->>Merge: merge_stream_tool_calls(Dict, 片段 N) Merge->>Dict: 槽位内 arguments 拼成完整 JSON 字符串 Note over ICE: 流结束 close stream ICE->>ICE: 用 Dict 按 index 排序生成 list ICE-->>ICE: message.tool_calls 写入 msg ICE-->>ICE: yield AGENT_CHAT_ROUND_DONE

流程图(单槽位随 chunk 累积)

flowchart TD C1[chunk N 到达] --> D{delta 存在?} D -->|否| SKIP[跳过该 chunk] D -->|是| PIECE{delta.content 非空?} PIECE -->|是| YDELTA[yield delta 并写入 content_parts] PIECE -->|否| TCHK YDELTA --> TCHK{delta.tool_calls 非空?} TCHK -->|否| NEXT[下一 chunk] TCHK -->|是| MERGE[merge_stream_tool_calls] MERGE --> SLOT[按 tc.index 取槽 slot] SLOT --> ACC[slot.function.name 与 arguments 字符串 += 碎片] ACC --> NEXT NEXT --> C1 EOS[流迭代结束] --> BUILD[msg.tool_calls 等于各 index 槽排序列表] BUILD --> DONE[yield AGENT_CHAT_ROUND_DONE]

与 generate_with_tools 的衔接

generate_with_tools 消费的仍是 delta 事件(来自 iter_chat_completion_events 的文本增量)和最后的 AGENT_CHAT_ROUND_DONE(其中 message.tool_calls 已是合并后的完整列表);合并过程本身对上层是透明的,都发生在 iter_chat_completion_events 内部每个 chunk 上调用 merge_stream_tool_calls 时完成。

27.3.8. 小结表 #

步骤 作用
build_openai_tools MCP list_tools → OpenAI tools + tool_mapping
iter_chat_completion_events 流式 delta + 合并 tool_calls → AGENT_CHAT_ROUND_DONE
iter_tool_run tool_start → MCP 执行 → tool_end → AGENT_CHAT_TOOL_DONE
messages 追加 assistant(可含 tool_calls)→ 多条 tool → 再请求模型
← 上一节 45.FunctionCalling 下一节 asyncio →

访问验证

请输入访问令牌

Token不正确,请重新输入