基于 MCP 的 LLM Agent 实战:架构设计与工具编排

本文介绍如何用 Model Context Protocol (MCP) 搭建可调用业务工具的 LLM Agent,涵盖整体架构、工具编排(next_status)、系统提示词与流式协议设计。文中的业务场景与示例代码均为通用化示例与 Mock,便于读者迁移到自己的业务。适合对 MCP、LangChain/LangGraph、大模型应用开发感兴趣的读者。


一、MCP 与 Agent 简介

1.1 MCP 是啥?

MCP 是一套开放协议,用来统一大模型和外部数据/工具的连接方式 。简单说:模型要"干活"的时候,不直接写死业务 API,而是和 MCP Server 通信,由 Server 暴露标准化的工具,模型按需调。

  • 核心:模型 ↔ MCP Server ↔ 你的业务(API、库、脚本等)
  • 好处:工具和模型解耦、可以多语言/多进程部署、工具复用方便,和 LangChain、Cursor 等生态也容易接

1.2 这里说的 Agent 指什么?

本文中的 Agent 指:在 LangGraph 中实现的 ReAct Agent------根据用户输入做"思考-行动-观察"循环,自主决定调用哪些 MCP 工具、以什么参数调用,并根据工具返回决定下一步(继续调用工具或直接回复用户)。

  • ReAct:Reasoning + Acting,先推理再选工具,再根据结果决定下一步
  • 工具从哪来:可以是 MCP Server(stdio/HTTP 子进程),也可以是自己写的 LangChain Tool,后面延伸里会一起说

二、整体架构

复制代码
┌─────────────────────────────────────────────────────────────────────────┐
│                        客户端 (Web / 语音端)                             │
└─────────────────────────────────────────────────────────────────────────┘
                    │ WebSocket (query, session_id, author...)
                    ▼
┌─────────────────────────────────────────────────────────────────────────┐
│  FastAPI 网关                                                           │
│  · Session 管理、用户校验、Redis 写入 (用户标识/位置/偏好)                 │
│  · 将 query 交给 AgentManager.run_agent(),流式 yield 消息               │
└─────────────────────────────────────────────────────────────────────────┘
                    │
                    ▼
┌─────────────────────────────────────────────────────────────────────────┐
│  AgentManager(MCP 客户端侧)                                            │
│  · LangGraph create_react_agent + ChatOpenAI(任意 OpenAPI 兼容 LLM)    │
│  · 加载 MCP 工具:convert_mcp_to_langchain_tools(stdio → mcp_server)     │
│  · 按 device_id/session 维护对话记忆 memory_message                      │
│  · 工具返回后:解析 next_status,组装 speak + payload 流式返回             │
└─────────────────────────────────────────────────────────────────────────┘
                    │ stdio 子进程
                    ▼
┌─────────────────────────────────────────────────────────────────────────┐
│  MCP Server (FastMCP)                                                   │
│  工具示例(Mock):venue_search, venue_select, slot_search, slot_select,  │
│                   booking_preview, create_booking, get_booking_info,    │
│                   cancel_booking                                        │
│  · 内部可调用:Redis / 业务 API / 地理服务 / 小模型校验 等(本文示例用 Mock)│
└─────────────────────────────────────────────────────────────────────────┘
                    │
                    ▼
┌─────────────────────────────────────────────────────────────────────────┐
│  外部依赖:Redis | MySQL(用户/偏好) | 业务后端 API | 地理服务 | LLM        │
└─────────────────────────────────────────────────────────────────────────┘

要点

  • 业务逻辑集中在 MCP Server,Agent 只负责"选工具 + 传参 + 理解返回"。
  • 会话与中间状态用 Redis ;用户身份与偏好可用 MySQL,新会话时做校验并加载到 Redis。

三、核心实现(示例都是 Mock / 通用化)

3.1 如何把 MCP 工具接到 LangGraph Agent?

使用 langchain_mcp_toolsconvert_mcp_to_langchain_tools,通过 stdio 拉起 MCP 子进程,拿到 LangChain 的 BaseTool 列表,再交给 create_react_agent

示例代码:

python 复制代码
import os
import sys
from langchain_mcp_tools import convert_mcp_to_langchain_tools
from langgraph.prebuilt import create_react_agent
from langchain_openai import ChatOpenAI
from langchain_core.prompts import ChatPromptTemplate, MessagesPlaceholder

project_root = os.path.dirname(os.path.abspath(__file__))
server_script = os.path.join(project_root, "mcp_server", "server.py")  # 你的 MCP 服务入口

# 1. MCP 连接配置(子进程 stdio)
connection = {
    "command": sys.executable,
    "args": [server_script],
    "transport": "stdio"
}
server_configs = {"my_business": connection}

# 2. 加载工具(返回 工具列表 + 清理函数)
tools, cleanup_fn = await convert_mcp_to_langchain_tools(server_configs)

# 3. LLM:任意 OpenAPI 兼容(如 OpenAI / 国产大模型)
llm = ChatOpenAI(model="gpt-4", api_key=os.getenv("OPENAI_API_KEY"), temperature=0.1)

# 4. 系统提示词 + 对话占位
prompt = ChatPromptTemplate.from_messages([
    ("system", "你是业务助手,请根据工具返回的 next_status 决定是否继续调用工具。"),
    MessagesPlaceholder(variable_name="messages")
])

# 5. 创建 ReAct Agent
agent = create_react_agent(llm, tools, prompt=prompt)

# 6. 流式运行(memory_messages 为当前会话消息列表,由上层按 device_id/session 维护)
async for chunk in agent.astream({"messages": memory_messages}):
    for node_name, node_output in chunk.items():
        if node_name == "agent":
            # 处理 AI 消息、tool_calls
            ...
        elif node_name == "tools":
            # 处理 ToolMessage,解析 next_status,组装前端协议
            ...

要点

  • 工具与主进程隔离,MCP Server 崩了不影响主服务,可单独重启或扩容。
  • 若有多组工具,可配置多个 server_configs,并行加载。

3.2 MCP Server 里工具长什么样?统一返回、next_status 与 Redis 状态

FastMCP 注册工具,每个工具是 async 函数,返回统一结构next_status (CONTINUE / INTERRUPT)+ data (JSON 字符串)+ status 。Agent 根据 next_status 决定是继续链式调用还是先对用户说话。多步流程中,Redis 作为会话级状态存储:上游工具写入搜索结果、选中项等,下游工具从 Redis 读取,保证"选场地 → 查时段 → 选时段 → 预览 → 创建"在同一会话内数据一致。

Mock 示例:工具注册、统一返回与 Redis 读写

python 复制代码
# mcp_server/server.py(示例,业务逻辑已 Mock,Redis 用于会话状态)
# FastMCP 可从 mcp.server.fastmcp 或 fastmcp 导入,视项目安装的包而定
import json
from mcp.server.fastmcp import FastMCP
from pydantic import Field

# Redis 客户端:从网关层注入或按 session 隔离,此处示意为全局单例
# 实际项目中可用 shared_instances.redis_client 或按 session_id 建 key 前缀
redis_client = None  # 初始化时注入,如 RedisUtils(host=..., port=...)

mcp = FastMCP(name="Demo Business MCP Server", host="0.0.0.0", port=8000)

def tool_response(data: dict, next_status: str, user_status: str = "CALM"):
    """统一工具返回格式,便于 Agent 解析。"""
    return {
        "next_status": next_status,  # CONTINUE:继续调用下一工具;INTERRUPT:先回复用户
        "data": json.dumps(data, ensure_ascii=False),
        "status": 200,
        "user_status": user_status
    }

async def redis_set(key: str, value):  # value 可为 dict,内部做 json.dumps
    """写入 Redis,key 建议带 session 前缀。"""
    if redis_client is None:
        return
    v = json.dumps(value, ensure_ascii=False) if isinstance(value, dict) else value
    await redis_client.set(key, v)

async def redis_get(key: str):
    """从 Redis 读取,自动做 json.loads(若为 JSON 字符串)。"""
    if redis_client is None:
        return None
    raw = await redis_client.get(key)
    if raw is None:
        return None
    try:
        return json.loads(raw) if isinstance(raw, str) else raw
    except json.JSONDecodeError:
        return raw

# ---------- 场地搜索 ----------
@mcp.tool()
async def venue_search(
    keyword: str = Field(default="", description="场地名称关键词,空表示不按名称筛选")
) -> dict:
    """根据位置或条件搜索可用场地(如会议室),返回场地列表。"""
    # 可选:从 Redis 取当前会话的经纬度/条件(由网关在建立会话时写入)
    longitude, latitude = None, None
    if redis_client:
        longitude = await redis_client.get("longitude")
        latitude = await redis_client.get("latitude")
    # Mock:实际用 longitude/latitude/keyword 调业务 API
    mock_venues = [
        {"venueId": "v1", "venueName": "A区会议室", "address": "A栋101"},
        {"venueId": "v2", "venueName": "B区会议室", "address": "B栋202"},
    ]
    payload = {"data": mock_venues}
    await redis_set("venue_search", payload)  # 写入 Redis,供 venue_select 校验
    return tool_response(payload, next_status="INTERRUPT", user_status="SHOW")

# ---------- 场地选择 ----------
@mcp.tool()
async def venue_select(
    venue_name: str = Field(description="用户选择的场地名称,须与 venue_search 返回列表一致")
) -> dict:
    """选择一处场地,后续时段/预约均在该场地下。"""
    search_data = await redis_get("venue_search")  # 从 Redis 取搜索结果
    list_data = (search_data or {}).get("data", [])
    selected = next((v for v in list_data if v.get("venueName") == venue_name), None)
    if not selected:
        return tool_response(
            {"message": f"未找到场地:{venue_name}"},
            next_status="INTERRUPT",
            user_status="SHOW"
        )
    await redis_set("venue_select", selected)  # 写入当前选中场地,供 slot_search / booking 使用
    return tool_response({"data": selected}, next_status="CONTINUE", user_status="CALM")

# ---------- 时段查询 ----------
@mcp.tool()
async def slot_search(
    date: str = Field(description="预约日期,如 2025-02-01"),
    duration: int = Field(default=1, description="预约时长(小时)")
) -> dict:
    """在当前选中的场地下查询可预约时段。"""
    venue = await redis_get("venue_select")
    if not venue:
        return tool_response(
            {"message": "请先选择场地"},
            next_status="INTERRUPT",
            user_status="SHOW"
        )
    # Mock:实际用 venue.venueId + date + duration 调业务 API
    mock_slots = [
        {"slotId": "t1", "timeRange": "09:00-10:00", "available": True},
        {"slotId": "t2", "timeRange": "10:00-11:00", "available": True},
    ]
    payload = {"data": mock_slots}
    await redis_set("slot_search", payload)
    await redis_set("slot_search_date", date)   # 可选:供预览/创建时使用
    await redis_set("slot_search_duration", duration)
    return tool_response(payload, next_status="INTERRUPT", user_status="SHOW")

# ---------- 时段选择 ----------
@mcp.tool()
async def slot_select(
    slot_id: str = Field(description="用户选择的时段 ID,须与 slot_search 返回一致")
) -> dict:
    """选择预约时段。"""
    search_data = await redis_get("slot_search")
    list_data = (search_data or {}).get("data", [])
    selected = next((s for s in list_data if s.get("slotId") == slot_id), None)
    if not selected:
        return tool_response(
            {"message": f"未找到时段:{slot_id}"},
            next_status="INTERRUPT",
            user_status="SHOW"
        )
    await redis_set("slot_select", selected)
    return tool_response({"data": selected}, next_status="CONTINUE", user_status="CALM")

# ---------- 预约预览 ----------
@mcp.tool()
async def booking_preview() -> dict:
    """预览当前预约信息(场地+时段等)。"""
    venue = await redis_get("venue_select")
    slot = await redis_get("slot_select")
    date = await redis_get("slot_search_date")  # 可选,由 slot_search 写入
    if not venue or not slot:
        return tool_response(
            {"message": "请先完成场地与时段选择"},
            next_status="INTERRUPT",
            user_status="SHOW"
        )
    mock_preview = {
        "venueName": venue.get("venueName"),
        "address": venue.get("address"),
        "timeRange": slot.get("timeRange"),
        "date": date or "2025-02-01",
    }
    await redis_set("booking_preview", mock_preview)
    return tool_response({"data": mock_preview}, next_status="INTERRUPT", user_status="SHOW")

# ---------- 创建预约 ----------
@mcp.tool()
async def create_booking() -> dict:
    """确认并创建预约。"""
    preview = await redis_get("booking_preview")
    if not preview:
        return tool_response(
            {"message": "请先预览预约信息"},
            next_status="INTERRUPT",
            user_status="SHOW"
        )
    # Mock:实际调业务创建接口,再写回订单号
    mock_booking = {"bookingId": "b001", "status": "created"}
    await redis_set("booking_id", mock_booking.get("bookingId"))
    return tool_response({"data": mock_booking}, next_status="INTERRUPT", user_status="SHOW")

# 其余工具如 get_booking_info(从 Redis 读 booking_id 再查详情)、cancel_booking 等
# 同样按 next_status 返回,并用 redis_get/redis_set 维护会话状态

if __name__ == "__main__":
    mcp.run(transport="stdio")

小结

  • Redis 里存当前会话的 venue_searchvenue_selectslot_search 等,下游工具只读 Redis 就能拿到上一步结果,不用让 Agent 把整块数据再传一遍。
  • 落地时 :key 一定要带 session_id 前缀(例如 {session_id}:venue_select),否则多会话会串数据;另外建议给这些 key 设 TTL(Time To Live,即过期时间,单位一般秒;到期后 Redis 自动删掉该 key),避免会话数据长期堆积。
  • next_status 完全由服务端按"要不要用户参与"来定,和 Redis 解耦。

next_status 怎么用

  • CONTINUE:本步成功了,Agent 就接着按流程调下一个工具(比如选完场地继续查时段)。
  • INTERRUPT :本步需要用户参与或展示结果,Agent 先回用户,别自动连调下一个工具(比如展示场地列表后等用户说"选第一个")。
  • 多步流程(搜场地→选场地→查时段→选时段→预览→创建预约)的"断点"由服务端显式控制,模型就不会乱猜、连续乱调。

3.3 系统提示词:约束工具用法与 next_status 遵守

Agent 容易在"何时用哪个工具、参数怎么传"上出错,用系统提示词 明确工具分工和规则,并强调必须遵守工具返回的 next_status

示例(通用化,工具名与上文 Mock 一致):

text 复制代码
你是一个多步任务助手,可以调用工具完成任务。

# 工具调用指引
- 场地:需要找/选场地时用 venue_search → venue_select
- 时段:在已选场地下用 slot_search → slot_select
- 预约:booking_preview → create_booking;查/取消用 get_booking_info / cancel_booking

# 工具入参要求
- 仅传扁平参数对象,参数名与工具定义完全一致
- 禁止在参数外再套 properties、schema、required、type 等
- slot_search 的 date、duration 等按工具定义传入

# 调用工具规则(重要)
- 必须检查工具返回的 next_status:
  - next_status == "CONTINUE" 时,按流程继续调用下一个工具
  - next_status == "INTERRUPT" 时,停止继续调用,先给用户回复
- 所有回复使用简洁自然语言,不要输出 JSON 或函数形式

系统提示词其实就是 Agent 的"说明书",把工具语义、参数约定、状态机规则写清楚,幻觉和乱调用会少很多。


3.4 流式协议:speak + payload 分离

我和前端的约定是:先给人话(speak),再给结构化数据(payload) 。工具跑完先不急着把 payload 推出去,而是先缓存;等 Agent 给出最后一句自然语言回复之后,再按顺序把缓存的 payload 一起发(场地列表、时段列表、预约预览等)。这样前端可以先播报再渲染列表,体验会顺一点。

逻辑示意(Mock 层级):

python 复制代码
# 工具执行完成后:只缓存 payload,不立刻 yield
payload_tools = {"venue_search", "venue_select", "slot_search", "slot_select", "booking_preview", "create_booking", "cancel_booking"}
cached_payloads = []

if msg.type == "tool" and msg.name in payload_tools:
    result = json.loads(msg.content)
    if result.get("status") == 200:
        # 根据 msg.name 从 Redis 或 result 取数据,组装前端所需结构
        payload_response = {"status": "context", "widget": {"speak": "", "subtype": msg.name, "payload": ...}}
        cached_payloads.append(payload_response)

# Agent 产出最终 ai_response 时:先 yield speak,再依次 yield 缓存的 payload
if ai_response:
    yield {"status": "context", "widget": {"speak": ai_response, "payload": "[]"}}
    for payload_response in cached_payloads:
        yield payload_response

流式场景下把"要说的话"和"要展示的数据"拆开,前端做 TTS 和列表/卡片会好配合一些。


四、其他用得上的点

4.1 两种 MCP 工具加载方式对比

特性 load_mcp_tools (单服务器) convert_mcp_to_langchain_tools (多服务器)
来源 langchain_mcp_adapters langchain_mcp_tools
服务器数量 单服务器 多服务器并行
返回值 List[BaseTool] (List[BaseTool], cleanup_fn)
资源管理 自动 需手动调用 cleanup_fn
错误与日志 基础 更强,适合生产

建议 :单 MCP、快速验证用 load_mcp_tools;多 MCP 或要精细控制连接生命周期(比如进程退出时关掉子进程)用 convert_mcp_to_langchain_tools,记得用完后调 cleanup_fn()

4.2 CTX、Redis 与 LangGraph state 的关系与选用

这里对比的是三种具体的状态/存储机制,不是抽象的"MCP 协议 context":

  • CTX :FastMCP 的 Context 对象 ,尤其是其 Session Statectx.get_state / ctx.set_state),在 MCP Server 内、按 MCP 会话存跨工具共享数据。
  • Redis:自建的、按业务 session_id 隔离的 Redis key,用来在 MCP 工具之间(或网关与 MCP 之间)共享数据。
  • LangGraph state :Agent 图的状态(如 messages),跑在调用 MCP 的那一侧,存多轮对话与推理结果。

下面分开说各自是啥、放什么、何时用谁。

1. CTX(FastMCP Context / Session State)

若你用的是 FastMCP ,框架在工具、resources、prompts 里注入一个 Context 对象(习惯叫 ctx),用来访问当前请求、日志、进度、以及会话级状态 等。官方文档:MCP Context

和"存数据"最相关的是 Session State :在同一 MCP 会话内,工具通过 ctx.get_state(key) / ctx.set_state(key, value) 存取值,按 MCP 会话隔离 ,无需自己拼 session_id。跨工具、同会话的中间结果(如搜索结果、选中项)可以直接放这里,不必单独接 Redis(单机或内存够用时)。分布式/多实例时,FastMCP 支持给 Session State 配自定义存储后端 (如 Redis、DynamoDB 等),见 Storage Backends

示例:工具里用 Context 的 Session State(替代自建 Redis key)

python 复制代码
# 此处用 fastmcp 包写法;若项目从 mcp.server.fastmcp 导入,则 Context 等路径以实际包为准
from fastmcp import FastMCP
from fastmcp.dependencies import CurrentContext
from fastmcp.server.context import Context

mcp = FastMCP(name="Demo")

@mcp.tool()
async def venue_search(keyword: str = "", ctx: Context = CurrentContext()) -> dict:
    # Mock 结果
    mock_venues = [{"venueId": "v1", "venueName": "A区会议室"}, ...]
    # 写入 Session State,同会话内后续工具可直接读(无需 Redis、无需 session_id 前缀)
    await ctx.set_state("venue_search", {"data": mock_venues})
    return tool_response({"data": mock_venues}, next_status="INTERRUPT", user_status="SHOW")

@mcp.tool()
async def venue_select(venue_name: str, ctx: Context = CurrentContext()) -> dict:
    # 从 Context Session State 读上一步结果,无需 Redis
    search_data = await ctx.get_state("venue_search") or {}
    list_data = search_data.get("data", [])
    selected = next((v for v in list_data if v.get("venueName") == venue_name), None)
    if not selected:
        return tool_response({"message": f"未找到场地:{venue_name}"}, next_status="INTERRUPT", user_status="SHOW")
    await ctx.set_state("venue_select", selected)
    return tool_response({"data": selected}, next_status="CONTINUE", user_status="CALM")

要点 (与 FastMCP 文档 一致):Context 对象 按单次请求注入,每个请求一个新的 context;但通过 Context 访问的 Session State (get_state/set_state)会在同一 MCP 会话的多次请求之间持久 ,所以适合存"跨工具、同会话"的中间结果。Session State 默认有过期时间(文档中为 1 天),避免无限增长;分布式时可配 Redis 等 Storage Backend。若你需要和网关层的 session_id 打通(例如网关用 Redis 存用户偏好),可以继续用自建 Redis;若全程在 MCP Server 内且用 FastMCP,优先用 Context 的 Session State 更简单。

ctx 能代替 Redis 吗?

  • 能代替 :当你要的只是「MCP Server 内部、同一 MCP 会话下、多步工具之间共享数据」(如 venue_search 结果给 venue_select 用)时,用 ctx.get_state / ctx.set_state 就够了,不必再自建 Redis、拼 session_id、设 TTL;单机时 FastMCP 用内存存,多实例时给 FastMCP 配 Redis 等 Storage Backend,由框架按 MCP 会话隔离。
  • 不能代替 :当网关和 MCP Server 要按"业务 session_id"共享数据 时(例如网关在 WebSocket 里拿到 session_id,把用户偏好、经纬度等写进 Redis,MCP 工具里再按 session_id 读),Context Session State 做不到------它只认 MCP 会话,不认你在网关里用的 session_id。这时要么继续用自建 Redis (key 带 session_id),要么通过 MCP 请求的 meta 等把网关侧数据带给工具,工具内从 ctx.request_context.meta 等读。
    所以:只做"工具间同会话共享"时,ctx 可以代替 Redis;要和网关按业务 session 共享时,不能代替,仍需共享存储或 meta 传参。

2. 文里的 Redis 在干啥?

Redis 在文里用来存 MCP Server 内部、同一用户会话下、多步工具之间要共享的业务状态,例如:

  • venue_search 的搜索结果列表;
  • venue_select 选中的场地;
  • slot_search / slot_select / booking_preview 等中间结果。

这样做的效果是:下游工具(如 venue_select)只需要从 Redis 按 key 读上一步结果,不用让 Agent 把整块数据(如整张门店列表)塞进对话或工具参数里。Agent 只传"选哪个"(如场地名、slot_id)等少量参数,大块数据留在 Server 侧,由 Redis(或你选的其它存储)在工具之间共享。

所以:Redis = MCP Server 侧的"跨工具、按会话维度的共享存储" (或等价存储),和 MCP 协议里的 "context" 不是同一个东西。若用 FastMCP ,也可以直接用 Context 的 Session Statectx.get_state / ctx.set_state)做同会话跨工具共享,不必自建 Redis key;多实例时再给 FastMCP 配 Redis 等 Storage Backend 即可。

3. LangGraph 的 state 是啥?

LangGraph stateAgent 图 的状态,跑在"调用 MCP 的进程"里(例如跑 create_react_agent 的那一侧),典型内容有:

  • messages:多轮对话里的人类消息、AI 消息、工具调用与工具返回(ToolMessage)。LLM 看到的"历史"就是这份。
  • 你自定义的其它图级字段(如当前步骤、元数据等)。

工具被调用时,只会收到 本次调用的 name + args ;工具内部再去读 Redis(或 FastMCP 的 ctx.get_state)拿"上一步的搜索结果"等。工具返回 的那一小段内容(如 {"next_status":"CONTINUE", "data":"..."})会以 ToolMessage 的形式写回 LangGraph state,所以 工具的输出会进 LangGraph state ,但 工具之间共享的中间数据(如完整列表)放在 Server 侧------用自建 Redis 或 FastMCP Context Session State,不全部塞进 LangGraph state。

4. 三者的关系(简图)

复制代码
用户请求 → [LangGraph state:messages 等]
                ↓ Agent 决定调工具、传 name + args
                ↓
         [MCP Client] ←→ 有状态会话 ←→ [MCP Server]
                ↑                            ↑
                │                            │ 工具内部读/写:
                │                            │ • Redis(自建 key,带 session_id)
                │                            │ 或 • CTX(ctx.get_state / set_state)
                │                            ↓
                │              [Redis 或 CTX Session State:venue_search, venue_select, ...]
                │                            ↑
                │ 工具返回 content 写回 LangGraph state
                ↓
         [LangGraph state:多了一条 ToolMessage]
  • CTX :FastMCP 的 Context 对象;工具内用 ctx.get_state / ctx.set_state 存"同 MCP 会话、跨工具"的数据,由框架按会话隔离(可选 Redis 等 Storage Backend)。
  • Redis:自建 key(带 session_id + TTL),在 MCP 工具之间或网关与 MCP 之间共享数据。
  • LangGraph state:Agent 侧对话与推理状态(如 messages),包含每步工具调用的输入/输出摘要。

5. 实战里怎么选、怎么用?

放什么 用谁 说明
多轮对话历史、当前轮消息、图级控制信息 LangGraph state 和推理、对话直接相关,LLM 要看的;别在这里放大块业务列表。
同一 MCP 会话下、多步工具之间要共享的业务数据(搜索结果、选中项、中间结果) CTXRedis CTX :用 FastMCP 时优先用 ctx.get_state/ctx.set_state,按 MCP 会话隔离、无需拼 key;多实例可配 Redis 等 Storage BackendRedis:不用 FastMCP、或需要和网关按业务 session_id 共享数据时,用自建 Redis(key 带 session_id + TTL)。

6. 代码示例:state、CTX、Redis 的读写

下面分别示意:(1) LangGraph state 的传入与更新;(2) CTX (Session State)在工具内的用法(见上);(3) Redis 按 session 隔离并设 TTL;(4) 用 Redis 时如何拿当前 session。

(1)LangGraph state:传入与更新

Agent 侧维护的 state 主要是 messages,每次请求把当前会话历史传进去,流式跑完后 state 里会多出本轮的 AI 消息和 ToolMessage。

python 复制代码
from langchain_core.messages import HumanMessage, AIMessage, ToolMessage

# 按 device_id 或 session_id 维护会话(即 LangGraph 的 state 中的 messages)
memory_message: list = []  # 或 self.memory_message[device_id]

# 本轮用户输入加入 state
memory_message.append(HumanMessage(content=query))

# 传入 state,流式跑 Agent;state 里就是 {"messages": memory_message}
async for chunk in agent.astream({"messages": memory_message}):
    for node_name, node_output in chunk.items():
        if node_name == "agent":
            # 从 node_output 里拿到本步产生的消息,追加到 state(对话历史)
            for msg in node_output.get("messages", []):
                memory_message.append(msg)
        elif node_name == "tools":
            for msg in node_output.get("messages", []):
                memory_message.append(msg)  # ToolMessage 写回 state

# 跑完后 memory_message 里多了本轮的 AIMessage(含 tool_calls)和 ToolMessage(工具返回摘要)
# 工具返回的 content 会进 state,但大块数据(如完整列表)放在 Redis 或 CTX,不塞进 state

(2)CTX(Session State):工具内读写

见上文「示例:工具里用 Context 的 Session State」:在 FastMCP 工具里注入 Context,用 ctx.set_state("venue_search", {...}) 写、ctx.get_state("venue_search") 读即可,无需 session_id 和 Redis key。

(3)Redis:按 session 隔离 + TTL

网关在进入会话时把 session_id 写入 Redis,MCP Server 里工具用「session_id + 业务 key」拼 key,并给 key 设过期时间,避免堆积。

python 复制代码
# ---------- 网关侧(如 FastAPI / WebSocket):进入会话时写入 session_id ----------
# 注意:单连接/单会话时可直接 set("session_id", session_id);多连接时建议用请求上下文或 meta 传 session_id,避免并发串会话
await redis_client.set("session_id", session_id)   # 当前请求的 session,供 MCP Server 读
# 可选:再写用户标识、位置等,供工具内使用
await redis_client.set(f"{session_id}:longitude", longitude)
await redis_client.set(f"{session_id}:latitude", latitude)

# ---------- MCP Server 侧:工具内用 session_id 拼 key,并设 TTL ----------
SESSION_TTL = 3600  # 1 小时,单位秒

async def redis_set(key: str, value, session_id: str = None):
    if redis_client is None:
        return
    if session_id is None:
        session_id = await redis_client.get("session_id") or "default"
    full_key = f"{session_id}:{key}"   # 按会话隔离
    v = json.dumps(value, ensure_ascii=False) if isinstance(value, dict) else value
    await redis_client.set(full_key, v)
    await redis_client.expire(full_key, SESSION_TTL)   # 到期自动删,避免堆积

async def redis_get(key: str, session_id: str = None):
    if redis_client is None:
        return None
    if session_id is None:
        session_id = await redis_client.get("session_id") or "default"
    full_key = f"{session_id}:{key}"
    raw = await redis_client.get(full_key)
    if raw is None:
        return None
    try:
        return json.loads(raw) if isinstance(raw, str) else raw
    except json.JSONDecodeError:
        return raw

# 工具里用 redis_get / redis_set 时不再手写 session_id,由上面从 Redis 取
# 例如:await redis_set("venue_select", selected);内部会拼成 {session_id}:venue_select 并设 TTL

(4)用 Redis 时:如何拿"当前 session"

若用 Redis 而不是 CTX 做跨工具共享,工具需要知道当前会话的 session_id 才能拼 key。我们约定:session_id 由网关写入 Redis ,MCP Server 里在需要时从 Redis 读 session_id,再拼业务 key。

python 复制代码
# MCP Server 内,工具入口处拿"当前 session"(从 Redis 读,由网关在请求前写入)
async def get_current_session_id() -> str:
    if redis_client is None:
        return "default"
    sid = await redis_client.get("session_id")
    return sid if isinstance(sid, str) else (str(sid) if sid is not None else "default")

# 工具里按 session 隔离读写
@mcp.tool()
async def venue_select(venue_name: str = Field(...)) -> dict:
    session_id = await get_current_session_id()   # "context":当前会话 id
    search_data = await redis_get("venue_search", session_id=session_id)
    # ...
    await redis_set("venue_select", selected, session_id=session_id)
    return tool_response({"data": selected}, next_status="CONTINUE", user_status="CALM")

小结(三者对比):

  • CTX :FastMCP 的 Context 对象;工具内用 ctx.get_state/ctx.set_state 存同 MCP 会话的跨工具数据,见上面(2)。
  • Redis:自建 key(session_id + 业务 key + TTL),工具间或网关与 MCP 共享数据;拿 session_id 的方式见上面(4)。
  • LangGraph state:Agent 的对话与推理状态(如 messages),见上面(1);大块共享数据放 CTX 或 Redis,不塞进 state。

4.3 会话与用户校验(新 session 时)

  • WebSocket 带上 session_idauthor(用户标识、经纬度等)。
  • session_id 一变就清掉当前会话在 Redis 里的 key,再写入新 session、用户标识、设备、位置等。
  • 只在新 session 做一次:按用户标识查 DB → 校验位置或身份(看业务)→ 把用户偏好写进 Redis,后面 MCP 工具直接用。

这样 Agent 的"上下文"不只有多轮对话,还有用户身份和偏好,做个性化或合规校验会方便很多。

4.4 依赖与运行方式

环境:Python 建议 3.10+(部分 MCP 相关库可能要求 3.11+,以官方文档为准)。

requirements.txt 示例

text 复制代码
# python>=3.10
# 核心 LangChain 框架
langchain>=0.3.0,<0.4.0
langchain-core>=0.3.0,<0.4.0

# LangGraph - ReAct Agent 支持
langgraph>=0.5.0,<0.6.0

# MCP 集成(二选一或按需)
langchain-mcp-adapters>=0.1.0,<0.2.0
langchain-mcp-tools>=0.1.0  # 3.1 节用 convert_mcp_to_langchain_tools 时需安装

# OpenAI 兼容 LLM
langchain-openai>=0.3.0,<0.4.0

# FastMCP 与 MCP 协议
fastmcp>=2.0.0,<3.0.0
mcp>=1.9.0,<2.0.0

# 基础依赖
pydantic>=2.0.0,<3.0.0
httpx>=0.25.0,<1.0.0
anyio>=4.0.0,<5.0.0
aiohttp>=3.12.13
fastapi
uvicorn[standard]
websockets

# 可选:会话/状态存储、业务 DB、LLM SDK 等按需添加
# aioredis>=2.0.0
# aiomysql>=0.2.0
bash 复制代码
# 安装
pip install -r requirements.txt -i https://pypi.tuna.tsinghua.edu.cn/simple

# 启动(MCP Server 由主进程按 stdio 拉起时无需单独起)
uvicorn app:app --host 0.0.0.0 --port 8089 --reload

WebSocket 请求示例(Mock)
{"query": "帮我查一下明天上午的会议室", "session_id": "xxx", "author": {"userId": "u1", "longitude": 116.4, "latitude": 39.9}}

4.5 流程图

  1. 用户一句话 → Agent 多步调用工具
    用户输入 → ReAct 推理 → 选工具(如 venue_search) → 工具返回 next_status=INTERRUPT → Agent 生成回复并下发 payload → 用户再说"选第一个" → venue_select → next_status=CONTINUE → slot_search → ...
  2. next_status 状态机
    CONTINUE / INTERRUPT 两种状态,配合"搜场地→选场地→查时段→选时段→预览→创建预约"的步骤。

五、小结与延伸

整体跑下来,我觉得这几件事比较关键:

  • MCP 把模型和业务工具拆开,用标准协议和进程边界,后面扩展、换实现都方便。
  • ReAct Agent + MCP:大模型只管"选工具、填参、理解结果",业务规则、状态、调后端 API 都放在 MCP Server 里。
  • next_status、speak/payload 分离、系统提示词约束、会话与用户校验 这几块和具体业务 API 无关,换一套业务也能复用。
  • 文里的场景和代码都是通用示例和 Mock,落地时把 MCP Server 里的 Mock 换成自己的 API 和存储就行。

落地时可以再注意:MCP 子进程/第三方 Server 建议配一下调用超时;Redis 里会话相关 key 建议设 TTL(见上面 3.2 小结),避免长期堆积;工具报错时要有明确返回(如 status 非 200 + message),方便 Agent 和前端做降级或提示。


延伸:LangChain Tool 的三种来源与合并使用

Agent 使用的 LangChain Tool 可以来自三类:(1) 从 0 开始自定义的原始 LangChain Tool 、(2) 自建 MCP Server 转换来的 Tool 、(3) 第三方 MCP Server 转换来的 Tool 。下面先说明原始自定义 Tool 的写法,再给一个合并三类 Tool 的完整示例

Tool 来源总览

来源 定义方式 得到 LangChain Tool 的方式
原始自定义 在 Python 里用 @toolBaseTool 直接写 本身就是 BaseTool,加入列表即可
自建 MCP Server 上面 3.2 的 mcp_server/server.py convert_mcp_to_langchain_tools(server_configs)
第三方 MCP Server 社区/官方(文件、Git、数据库等) 同上,server_configs 指向该 Server

从 0 开始:自定义原始 LangChain Tool

不依赖 MCP,在 Python 里用 LangChain 的 @tool 或继承 BaseTool 就能定义一个 Tool,对 Agent 来说和 MCP 转过来的 Tool 用法一样。

python 复制代码
from langchain_core.tools import tool

# 方式一:@tool 装饰器(最简)
@tool
def get_current_time(timezone: str = "Asia/Shanghai") -> str:
    """获取当前时间。参数 timezone 为时区,如 Asia/Shanghai、UTC。"""
    from datetime import datetime
    import zoneinfo
    tz = zoneinfo.ZoneInfo(timezone)
    return datetime.now(tz).strftime("%Y-%m-%d %H:%M:%S")

# 方式二:多参数、带描述
@tool
def simple_calc(expr: str) -> str:
    """计算简单数学表达式,仅支持 + - * / 与整数,如 '1+2*3'。"""
    try:
        # 示例用 eval,生产环境请用安全表达式解析(如 ast.literal_eval 或专用库)
        return str(eval(expr))
    except Exception as e:
        return f"计算失败: {e}"

# 得到的就是 LangChain BaseTool,可直接放进 tools 列表
custom_tools = [get_current_time, simple_calc]

需要 async 或自定义 Schema 时,可以继承 BaseTool 实现 _run / _arun,这里就不展开了。


完整示例:三类 Tool 合并后交给 Agent

下面这段代码把 原始自定义 Tool自建 MCP Server 转成的 Tool第三方 MCP Server 转成的 Tool 都放进一个 tools 列表,再交给 create_react_agent,是我自己项目里常用的写法。

python 复制代码
import os
import sys
from langchain_core.tools import tool
from langchain_mcp_tools import convert_mcp_to_langchain_tools
from langgraph.prebuilt import create_react_agent
from langchain_openai import ChatOpenAI
from langchain_core.prompts import ChatPromptTemplate, MessagesPlaceholder

# ---------- 1)原始自定义 LangChain Tool(从 0 写,不经过 MCP)----------
@tool
def get_current_time(timezone: str = "Asia/Shanghai") -> str:
    """获取当前时间。参数 timezone 为时区,如 Asia/Shanghai。"""
    from datetime import datetime
    import zoneinfo
    return datetime.now(zoneinfo.ZoneInfo(timezone)).strftime("%Y-%m-%d %H:%M:%S")

custom_tools = [get_current_time]

# ---------- 2)自建 MCP Server → LangChain Tool ----------
project_root = os.path.dirname(os.path.abspath(__file__))
server_script = os.path.join(project_root, "mcp_server", "server.py")
connection_business = {
    "command": sys.executable,
    "args": [server_script],
    "transport": "stdio"
}

# ---------- 3)第三方 MCP Server → LangChain Tool(示例:文件系统,命令按实际文档)----------
connection_fs = {
    "command": "npx",
    "args": ["-y", "@modelcontextprotocol/server-filesystem", "/tmp"],
    "transport": "stdio"
}

server_configs = {
    "my_business": connection_business,
    "filesystem": connection_fs,
}
mcp_tools, cleanup_fn = await convert_mcp_to_langchain_tools(server_configs)

# ---------- 4)合并三类 Tool:自定义 + 自建 MCP + 第三方 MCP ----------
tools = custom_tools + mcp_tools
# tools 中现包含:get_current_time,以及 venue_search / venue_select / slot_search / ...,以及第三方 Server 暴露的工具(如文件系统 Server 的 read_text_file 等,以该 Server 文档为准)

llm = ChatOpenAI(model="gpt-4", api_key=os.getenv("OPENAI_API_KEY"), temperature=0.1)
prompt = ChatPromptTemplate.from_messages([
    ("system", "你是业务助手,可使用当前时间、场地预约与文件系统工具。请根据工具返回的 next_status 决定是否继续调用。"),
    MessagesPlaceholder(variable_name="messages")
])
agent = create_react_agent(llm, tools, prompt=prompt)
# 使用完毕后如需释放 MCP 连接,可调用 await cleanup_fn()

1. 第三方 / 社区 MCP Server

除了自己写 MCP Server,也可以直接用第三方或社区现成的 MCP Server ,不用从零写工具逻辑。常见的有文件系统、Git、数据库、Slack、Notion 等,按 MCP 协议暴露一组工具(名称、描述、参数 schema)。在 MCP 官网或 GitHub 等平台搜 "MCP server" 能找到不少列表和示例仓库。用法和自建一样:stdio/HTTP 连上,用 load_mcp_toolsconvert_mcp_to_langchain_tools 拉工具列表,再和自己写的 Tool 合并后交给 Agent(见上面完整示例)。这样"读文件、查库、发消息"之类能力可以快速接进来,业务独有的部分再写进自建 MCP Server 或直接用原始 LangChain Tool 从 0 写。

2. 和 LangChain 的对接:统一用 Tool,底层可能是 Function Calling

  • 说法上 :我习惯统一说 Tool 。不管是手写的 @tool、自建 MCP 转的,还是第三方 MCP 转的,在 LangChain 里都是 BaseTool,对 Agent 来说是一类东西,合并成一个列表传进去就行(见上面完整示例)。

  • Tool 和 Function Calling
    Function Calling(FC) 指的是:LLM 接口支持传"函数列表",模型直接返回要调用的函数名和参数(结构化 tool_calls)。在 LangChain 里,你把 Tool 列表传给 Agent 后,如果底层 LLM 支持 FC,就会用 FC 返回"调哪个 Tool、参数是啥",LangChain 再按这个去执行。所以:对外说「统一成 LangChain Tool 给 Agent 用」就行;如果别人问"模型怎么选工具",可以补一句「很多模型通过 Function Calling 直接返回工具名和参数,LangChain 会拿去执行对应 Tool」。

3. 简单对比(方便选型)

概念 含义 我自己的用法
原始 LangChain Tool @toolBaseTool 在 Python 里直接写 小能力(时间、计算等)从 0 写,不经过 MCP
MCP Tool MCP Server 暴露的能力(name + description + parameters) 自建或第三方 Server 提供
LangChain Tool(MCP 转) load_mcp_tools / convert_mcp_to_langchain_tools 把 MCP 工具转成 BaseTool 和原始 Tool 一样,进同一个列表
Function Calling LLM API 能力:收工具列表,返回结构化 tool_calls 底层实现之一,LangChain 会用来执行对应 Tool

总结:以 Tool 为主线(原始 + 自建/第三方 MCP → 统一 LangChain Tool → Agent),需要时提一句「底层可能是 Function Calling」就够了。


延伸阅读(按需翻):

  • MCP 官方规范与 SDK
  • LangChain 的 MCP 适配:langchain-mcp-adapters / langchain-mcp-tools
  • LangGraph 的 ReAct Agent 与多节点图
  • 任意 OpenAPI 兼容的 LLM 接入(含 Function Calling)
相关推荐
Christo32 小时前
TFS-2026《Fuzzy Multi-Subspace Clustering 》
人工智能·算法·机器学习·数据挖掘
五点钟科技2 小时前
Deepseek-OCR:《DeepSeek-OCR: Contexts Optical Compression》 论文要点解读
人工智能·llm·ocr·论文·大语言模型·deepseek·deepseek-ocr
人工智能AI技术2 小时前
【C#程序员入门AI】本地大模型落地:用Ollama+C#在本地运行Llama 3/Phi-3,无需云端
人工智能·c#
Agentcometoo2 小时前
智能体来了从 0 到 1:规则、流程与模型的工程化协作顺序
人工智能·从0到1·智能体来了·时代趋势
工程师老罗2 小时前
什么是目标检测?
人工智能·目标检测·计算机视觉
jarreyer2 小时前
【AI 编程工具】
人工智能·编程工具
阿杰学AI2 小时前
AI核心知识75——大语言模型之MAS (简洁且通俗易懂版)
人工智能·ai·语言模型·自然语言处理·agent·多智能体协作·mas
小程故事多_802 小时前
深度搜索Agent架构全解析:从入门到进阶,解锁复杂问题求解密码
人工智能·架构·aigc
朴实赋能2 小时前
AI赋能文旅出海:智矩引擎(MatriPower)社媒矩阵破局与流量长效增长实操指南
人工智能·社媒矩阵·matripower·文旅出海·海外社媒引流·文旅ip出海·智矩引擎