本文介绍如何用 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_tools 的 convert_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_search、venue_select、slot_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 State (
ctx.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 State (ctx.get_state / ctx.set_state)做同会话跨工具共享,不必自建 Redis key;多实例时再给 FastMCP 配 Redis 等 Storage Backend 即可。
3. LangGraph 的 state 是啥?
LangGraph state 是 Agent 图 的状态,跑在"调用 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 会话下、多步工具之间要共享的业务数据(搜索结果、选中项、中间结果) | CTX 或 Redis | CTX :用 FastMCP 时优先用 ctx.get_state/ctx.set_state,按 MCP 会话隔离、无需拼 key;多实例可配 Redis 等 Storage Backend。Redis:不用 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_id、author(用户标识、经纬度等)。 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 流程图
- 用户一句话 → Agent 多步调用工具 :
用户输入 → ReAct 推理 → 选工具(如 venue_search) → 工具返回 next_status=INTERRUPT → Agent 生成回复并下发 payload → 用户再说"选第一个" → venue_select → next_status=CONTINUE → slot_search → ... - 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 里用 @tool 或 BaseTool 直接写 |
本身就是 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_tools 或 convert_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 | 用 @tool 或 BaseTool 在 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)