1. 为什么我要把 MCP 协议引入 AI 编程智能体
先说结论:如果你正在做 AI 编程助手、代码生成 Agent,或者任何需要让大模型“动手干活”的系统,MCP 协议值得你花时间认真研究。我在过去大半年里,先后用 LangChain、LangGraph 搭过几套编程智能体,从最早的纯 Prompt 拼接,到后来的 Function Calling,再到现在的 MCP 工具链,踩过的坑足够写一本小册子。这篇文章就把我在商业级 AI 编程智能体落地过程中的完整思路、技术选型、实操细节和避坑经验全部摊开讲。
MCP,全称 Model Context Protocol,本质上是一套让大模型与外部工具、数据源之间标准化通信的协议。你可以把它理解成“AI 世界的 USB-C 接口”——以前每个工具都要写一套适配代码,现在只要工具实现了 MCP Server,任何支持 MCP 的客户端都能直接调用。这个类比不是我的原创,但确实是最贴切的。对于编程智能体来说,MCP 解决的核心痛点是:工具接入的标准化和上下文管理的规范化。
那为什么是“商业级”?因为玩具级的 Agent 和商业级的 Agent 之间,隔着的不只是模型能力,还有并发处理、错误恢复、权限控制、可观测性、成本控制这一整套工程体系。我见过太多团队用 LangChain 花两天搭出一个 Demo,然后花两个月都没能把它变成能上生产的东西。问题往往不出在模型上,而是出在架构设计上。
这篇文章适合谁看?如果你是有一定 Python 基础、了解 LangChain 基本用法、想把自己的 AI 编程助手从 Demo 推进到生产环境的开发者,那这篇内容就是为你写的。如果你刚接触 Agent 概念,也没关系,我会在关键节点补充基础说明,保证你能跟上节奏。
2. 整体架构设计与技术选型思路
2.1 为什么选 MCP + LangChain + LangGraph 这套组合
在动手之前,我先说说技术选型的逻辑。市面上做 AI 编程智能体的方案大致分三类:纯 Prompt 工程、Function Calling 原生方案、以及基于协议的工具调用方案。我最终选择 MCP + LangChain + LangGraph,原因有三。
第一,MCP 解决了工具生态的复用问题。在没有 MCP 之前,我每接一个工具——比如代码检索、文件读写、终端执行、Git 操作——都要在 Agent 里写一套专门的 Tool 定义和调用逻辑。工具一多,代码就变成了一团乱麻。MCP 把这些工具抽象成独立的 Server,Agent 只需要作为 Client 去连接,工具的实现和 Agent 的逻辑彻底解耦。这意味着我可以直接用社区里现成的 MCP Server,比如文件系统操作、数据库查询、浏览器自动化,不用重复造轮子。
第二,LangChain 提供了成熟的 LLM 抽象层。虽然 LangChain 经常被吐槽抽象过度,但在商业级场景下,它的价值在于统一了不同模型提供商的接口。今天用这个模型,明天换那个模型,业务代码基本不用动。而且它的 Callback 机制对于做可观测性非常友好,后面讲监控的时候会细说。
第三,LangGraph 解决了复杂编排问题。编程智能体不是简单的“输入-调用-输出”线性流程,它需要循环、分支、状态管理、人工介入。LangGraph 用图的方式描述 Agent 的执行流程,比传统的 Chain 灵活太多。特别是它的 Checkpoint 机制,让中断恢复和 Human-in-the-loop 变得非常自然。
提示:如果你现在的项目还在用纯 Function Calling,工具数量少于 5 个,其实不急着上 MCP。但当工具超过 10 个,或者你需要跨项目复用工具时,MCP 的收益会非常明显。
2.2 商业级智能体的分层架构
我把整个系统分成四层,从下到上依次是:工具层、协议层、编排层、应用层。这个分层不是拍脑袋想的,而是根据实际运维中“哪一层出问题就改哪一层”的原则倒推出来的。
工具层是各种 MCP Server,每个 Server 负责一类能力。比如filesystem-server负责文件读写,git-server负责版本控制操作,code-search-server负责代码语义检索。这些 Server 可以独立部署、独立升级,互不影响。
协议层是 MCP Client 的实现,负责与各个 Server 建立连接、管理会话、处理消息序列化。这一层的关键是连接池管理和超时控制,后面会详细讲。
编排层是 LangGraph 构建的 Agent 执行图,包含意图识别、任务规划、工具调用、结果验证、错误重试等节点。这是整个系统的大脑。
应用层是对外的 API 和交互界面,负责接收用户请求、管理会话状态、返回流式结果。
这样分层的好处是,每一层都可以独立测试和替换。比如我想换掉 LangChain 换成别的框架,只需要重写编排层,工具层和协议层完全不用动。
2.3 关键设计决策与取舍
在实际落地过程中,有几个决策点值得展开说。
决策一:MCP Server 用本地进程还是远程服务?我最初把所有 MCP Server 都跑在本地,通过 stdio 通信。好处是延迟低、部署简单。但问题是,当多个 Agent 实例需要共享工具时,本地进程模式就不行了。后来我改成了混合模式:高频、轻量的工具(如文件读写)走本地 stdio,重型的、需要共享的工具(如代码索引服务)走远程 SSE 或 Streamable HTTP。这个取舍的核心依据是调用频率和状态共享需求。
决策二:Agent 的状态存哪里?LangGraph 默认用内存做 Checkpoint,这在开发阶段没问题,但生产环境必须持久化。我试过 Redis、PostgreSQL 和 SQLite 三种方案。Redis 读写最快,适合高频短会话;PostgreSQL 适合需要复杂查询和长期存储的场景;SQLite 适合单机部署的小规模应用。最终我选了 PostgreSQL,因为编程智能体的会话往往需要保留较长时间,而且我需要按用户、按项目维度做统计分析。
决策三:流式输出怎么处理?编程场景下,用户对响应速度非常敏感。我的方案是双层流式:LLM 的 token 流式输出 + 工具执行结果的流式返回。LangGraph 的astream_events接口可以同时捕获这两类事件,前端通过 SSE 接收。这里有个坑,MCP 协议本身的消息格式和 LangChain 的事件格式不一致,需要做一层转换,后面实操部分会给出代码。
3. 核心细节解析与实操要点
3.1 MCP 协议的核心概念拆解
在写代码之前,必须把 MCP 的几个核心概念搞清楚,否则后面调试会非常痛苦。
Resources(资源):这是 MCP Server 暴露给 LLM 的只读数据。比如文件内容、数据库查询结果、API 返回的数据。Resources 的特点是“被动读取”,LLM 通过 URI 来访问。在编程智能体里,代码文件、Git 历史、依赖清单都可以作为 Resource 暴露。
Tools(工具):这是 MCP Server 暴露的可执行操作。和 Resources 不同,Tools 会改变状态或产生副作用。比如写文件、执行命令、创建分支。Tools 的定义包含名称、描述、参数 Schema,LLM 根据这些信息决定是否调用。
Prompts(提示模板):这是 MCP Server 预定义的提示模板,可以帮助 LLM 更好地使用该 Server 的能力。实际项目中我用得不多,因为 Agent 的提示词通常由编排层统一管理,但某些专业工具(如 SQL 生成)自带 Prompt 模板确实能提升效果。
Sampling(采样):这是 MCP 的一个高级特性,允许 Server 反向请求 Client 的 LLM 能力。比如一个代码审查 Server 在处理文件时,可以请求 LLM 帮忙分析代码质量。这个特性在商业级场景下很有用,但要注意权限控制,防止 Server 滥用 LLM 调用。
注意:MCP 的 Tools 和 Resources 边界有时候会模糊。我的经验法则是:如果操作是幂等的、只读的,就做成 Resource;如果有副作用或需要复杂参数,就做成 Tool。这个划分直接影响 LLM 的调用决策准确率。
3.2 编程智能体的工具集设计
一个商业级 AI 编程智能体需要哪些工具?我根据实际项目经验,整理了一份工具清单,按优先级排序。
| 优先级 | 工具类别 | 具体能力 | 实现方式 |
|---|---|---|---|
| P0 | 文件操作 | 读、写、搜索、替换 | 本地 MCP Server |
| P0 | 代码执行 | 运行脚本、单元测试 | 沙箱 MCP Server |
| P0 | 版本控制 | 查看 diff、提交、分支 | Git MCP Server |
| P1 | 代码检索 | 语义搜索、符号查找 | 远程 MCP Server |
| P1 | 依赖管理 | 查询、安装、更新依赖 | 本地 MCP Server |
| P2 | 文档查询 | API 文档、框架文档 | 远程 MCP Server |
| P2 | 终端操作 | 执行 shell 命令 | 沙箱 MCP Server |
这份清单不是拍脑袋定的,而是根据“编程任务中出现频率”和“LLM 自主完成的难度”两个维度筛选出来的。P0 级别的工具几乎每个编程任务都会用到,必须优先实现且保证稳定。P1 级别的是提升效率的,P2 级别的是锦上添花的。
这里重点说下代码执行工具的设计。这是最危险也最有价值的工具。危险在于,让 LLM 执行任意代码可能造成安全风险;价值在于,没有代码执行能力,Agent 就无法验证自己生成的代码是否正确。我的方案是:所有代码执行都在 Docker 沙箱中进行,限制网络访问、限制文件系统范围、设置执行超时。沙箱镜像预装了常用语言运行时和测试框架,Agent 生成的代码直接在沙箱里跑,结果返回给 Agent 做下一步决策。
3.3 上下文管理与 Token 预算控制
编程智能体面临的一个核心挑战是:代码文件的上下文非常长,很容易超出模型的 Token 限制。我见过太多项目在这里翻车——要么截断代码导致 LLM 理解错误,要么塞太多内容导致成本失控。
我的上下文管理策略分三层。
第一层:按需加载。不要一上来就把整个代码库塞给 LLM。Agent 应该先通过代码检索工具定位相关文件,再按需读取。这要求代码检索工具足够精准,我通常用向量检索 + 符号索引的混合方案。
第二层:智能摘要。对于长文件,不是简单截断,而是用 LLM 生成结构化摘要。摘要包含:文件职责、关键类/函数签名、依赖关系、最近修改。这样 LLM 能在不读全文的情况下理解文件作用。
第三层:Token 预算动态分配。我给每次 LLM 调用设置 Token 预算,根据任务复杂度动态调整。简单任务(如格式化代码)预算小,复杂任务(如重构)预算大。预算分配逻辑用 LangGraph 的条件边实现。
# Token 预算配置示例 TOKEN_BUDGET = { "simple_edit": {"input": 4000, "output": 2000}, "feature_impl": {"input": 16000, "output": 8000}, "refactor": {"input": 32000, "output": 16000}, "debug": {"input": 24000, "output": 8000}, } def allocate_budget(task_type: str, complexity_score: float) -> dict: base = TOKEN_BUDGET.get(task_type, TOKEN_BUDGET["simple_edit"]) # 根据复杂度分数微调,范围 0.5x 到 1.5x factor = 0.5 + complexity_score return {k: int(v * factor) for k, v in base.items()}这个预算机制配合 LangChain 的trim_messages使用,效果很稳。实测下来,相比无脑塞上下文,Token 消耗降低了约 60%,而任务成功率反而提升了,因为 LLM 接收到的信息更聚焦。
3.4 错误处理与重试机制
商业级系统和 Demo 的最大区别之一就是错误处理。LLM 调用会失败、MCP Server 会超时、工具执行会报错、生成的代码会有语法错误。这些都必须有预案。
我的错误处理分四级。
第一级:瞬时错误自动重试。网络抖动、限流导致的失败,用指数退避重试,最多 3 次。LangChain 的with_retry装饰器可以直接用。
第二级:工具错误反馈给 LLM。如果工具执行失败,不是直接抛异常,而是把错误信息作为 ToolMessage 返回给 LLM,让 LLM 决定下一步。比如代码执行报错,LLM 看到错误信息后可以自动修复。
第三级:任务级回滚。如果某个子任务连续失败,LangGraph 的 Checkpoint 机制可以回滚到上一个稳定状态,重新规划。
第四级:人工介入。对于高风险操作(如删除文件、强制推送),设置 Human-in-the-loop 节点,等待人工确认。
from langgraph.graph import StateGraph from langgraph.checkpoint.postgres import PostgresSaver # 构建带错误处理的 Agent 图 builder = StateGraph(AgentState) builder.add_node("plan", plan_node) builder.add_node("execute", execute_node) builder.add_node("verify", verify_node) builder.add_node("human_review", human_review_node) # 条件边:验证失败时决定重试还是人工介入 builder.add_conditional_edges( "verify", should_retry_or_escalate, { "retry": "execute", "escalate": "human_review", "done": "__end__" } ) checkpointer = PostgresSaver.from_conn_string("postgresql://...") graph = builder.compile( checkpointer=checkpointer, interrupt_before=["human_review"] )这套机制上线后,我统计过,约 85% 的工具错误能被 LLM 自动修复,剩下 15% 中大部分能通过重试解决,真正需要人工介入的不到 2%。
4. 实操过程与核心环节实现
4.1 环境搭建与依赖安装
先把基础环境搭起来。我假设你用的是 Python 3.11+,这是目前 LangChain 和 MCP SDK 兼容性最好的版本。
# 创建虚拟环境 python -m venv venv source venv/bin/activate # Windows 用 venv\Scripts\activate # 安装核心依赖 pip install langchain==0.3.x langchain-openai langgraph mcp pip install fastapi uvicorn sse-starlette pip install psycopg[binary] redis pip install docker # 用于沙箱管理MCP 的 Python SDK 目前迭代很快,建议锁定版本。我用的组合是mcp==1.x+langchain-mcp-adapters,后者是 LangChain 官方出的适配器,能把 MCP 工具直接转成 LangChain Tool。
pip install langchain-mcp-adapters提示:如果你在国内,pip 安装可能较慢,建议配置镜像源。另外,MCP SDK 的某些版本对 Python 版本有要求,遇到兼容性问题先检查版本。
4.2 编写第一个 MCP Server
我从最基础的文件操作 Server 开始写,让你理解 MCP Server 的结构。
# filesystem_server.py import asyncio import os from mcp.server import Server from mcp.server.stdio import stdio_server from mcp.types import Tool, TextContent app = Server("filesystem-server") # 定义工具列表 @app.list_tools() async def list_tools() -> list[Tool]: return [ Tool( name="read_file", description="读取指定路径的文件内容", inputSchema={ "type": "object", "properties": { "path": {"type": "string", "description": "文件路径"} }, "required": ["path"] } ), Tool( name="write_file", description="将内容写入指定路径的文件", inputSchema={ "type": "object", "properties": { "path": {"type": "string"}, "content": {"type": "string"} }, "required": ["path", "content"] } ), Tool( name="list_directory", description="列出目录下的文件和子目录", inputSchema={ "type": "object", "properties": { "path": {"type": "string"} }, "required": ["path"] } ) ] # 实现工具调用 @app.call_tool() async def call_tool(name: str, arguments: dict) -> list[TextContent]: if name == "read_file": path = arguments["path"] # 安全检查:限制在工作目录内 if not is_safe_path(path): return [TextContent(type="text", text="错误:路径超出允许范围")] try: with open(path, "r", encoding="utf-8") as f: content = f.read() return [TextContent(type="text", text=content)] except Exception as e: return [TextContent(type="text", text=f"读取失败:{str(e)}")] elif name == "write_file": path = arguments["path"] content = arguments["content"] if not is_safe_path(path): return [TextContent(type="text", text="错误:路径超出允许范围")] try: os.makedirs(os.path.dirname(path), exist_ok=True) with open(path, "w", encoding="utf-8") as f: f.write(content) return [TextContent(type="text", text=f"已写入 {len(content)} 字符")] except Exception as e: return [TextContent(type="text", text=f"写入失败:{str(e)}")] elif name == "list_directory": path = arguments["path"] if not is_safe_path(path): return [TextContent(type="text", text="错误:路径超出允许范围")] try: entries = os.listdir(path) result = "\n".join(entries) return [TextContent(type="text", text=result)] except Exception as e: return [TextContent(type="text", text=f"列出失败:{str(e)}")] def is_safe_path(path: str) -> bool: """确保路径在工作目录内,防止路径穿越""" work_dir = os.path.abspath(os.getcwd()) target = os.path.abspath(path) return target.startswith(work_dir) async def main(): async with stdio_server() as (read_stream, write_stream): await app.run(read_stream, write_stream, app.create_initialization_options()) if __name__ == "__main__": asyncio.run(main())这个 Server 虽然简单,但包含了 MCP Server 的核心要素:工具定义、参数 Schema、调用实现、安全检查。特别注意is_safe_path这个函数,这是防止 LLM 误操作的关键。我见过真实案例,Agent 因为路径校验缺失,把文件写到了系统目录,后果很严重。
4.3 在 LangGraph 中集成 MCP 工具
Server 写好了,接下来是在 Agent 里连接它。用langchain-mcp-adapters可以几行代码搞定。
from langchain_mcp_adapters.client import MultiServerMCPClient from langgraph.prebuilt import create_react_agent from langchain_openai import ChatOpenAI # 配置 MCP Server 连接 mcp_client = MultiServerMCPClient({ "filesystem": { "command": "python", "args": ["filesystem_server.py"], "transport": "stdio" }, "git": { "command": "python", "args": ["git_server.py"], "transport": "stdio" }, "code_search": { "url": "http://localhost:8080/sse", "transport": "sse" } }) async def build_agent(): # 获取所有 MCP 工具 tools = await mcp_client.get_tools() # 创建 LLM llm = ChatOpenAI(model="gpt-4o", temperature=0) # 创建 ReAct Agent agent = create_react_agent(llm, tools) return agent这里有个细节要注意:MultiServerMCPClient支持 stdio 和 SSE 两种传输方式。stdio 适合本地进程,SSE 适合远程服务。混合使用完全没问题,客户端会自动处理。
但create_react_agent只是入门级方案,商业级场景下我建议用自定义的 LangGraph 图,因为需要更精细的控制。下面是我实际项目中的图结构。
from typing import TypedDict, Annotated from langgraph.graph import StateGraph, END from langgraph.graph.message import add_messages class AgentState(TypedDict): messages: Annotated[list, add_messages] task_plan: list[str] current_step: int context: dict error_count: int async def plan_node(state: AgentState): """任务规划节点""" llm = ChatOpenAI(model="gpt-4o", temperature=0) planner_prompt = """你是一个编程任务规划专家。根据用户需求,拆解成可执行的步骤。 每个步骤应该是一个明确的操作,比如"读取文件X"、"修改函数Y"、"运行测试Z"。 输出 JSON 格式的步骤列表。""" response = await llm.ainvoke([ {"role": "system", "content": planner_prompt}, *state["messages"] ]) plan = parse_plan(response.content) return {"task_plan": plan, "current_step": 0} async def execute_node(state: AgentState): """执行节点:调用工具""" tools = await mcp_client.get_tools() llm = ChatOpenAI(model="gpt-4o", temperature=0).bind_tools(tools) response = await llm.ainvoke(state["messages"]) return {"messages": [response]} async def verify_node(state: AgentState): """验证节点:检查执行结果""" # 检查最后一条消息是否包含错误 last_msg = state["messages"][-1] if has_error(last_msg): return {"error_count": state["error_count"] + 1} return {"error_count": 0} def should_continue(state: AgentState): """决定下一步走向""" if state["error_count"] >= 3: return "escalate" last_msg = state["messages"][-1] if hasattr(last_msg, "tool_calls") and last_msg.tool_calls: return "execute" if state["current_step"] < len(state["task_plan"]) - 1: return "next_step" return "done" # 构建图 builder = StateGraph(AgentState) builder.add_node("plan", plan_node) builder.add_node("execute", execute_node) builder.add_node("verify", verify_node) builder.set_entry_point("plan") builder.add_edge("plan", "execute") builder.add_edge("execute", "verify") builder.add_conditional_edges( "verify", should_continue, { "execute": "execute", "next_step": "execute", "escalate": END, "done": END } ) graph = builder.compile(checkpointer=checkpointer)这个图结构比create_react_agent复杂,但可控性强很多。规划节点负责拆解任务,执行节点负责调用工具,验证节点负责检查结果。条件边根据验证结果决定是继续执行、重试还是升级。
4.4 流式输出的实现细节
编程场景下,用户需要实时看到 Agent 的思考过程和代码生成。我用 SSE 实现流式输出,核心是监听 LangGraph 的事件流。
from fastapi import FastAPI from fastapi.responses import StreamingResponse import json app = FastAPI() @app.post("/agent/stream") async def stream_agent(request: AgentRequest): async def event_generator(): config = {"configurable": {"thread_id": request.session_id}} async for event in graph.astream_events( {"messages": [{"role": "user", "content": request.prompt}]}, config=config, version="v2" ): kind = event["event"] # LLM token 流式输出 if kind == "on_chat_model_stream": chunk = event["data"]["chunk"] if chunk.content: yield f"data: {json.dumps({'type': 'token', 'content': chunk.content})}\n\n" # 工具调用开始 elif kind == "on_tool_start": yield f"data: {json.dumps({'type': 'tool_start', 'name': event['name'], 'input': event['data'].get('input')})}\n\n" # 工具调用结束 elif kind == "on_tool_end": yield f"data: {json.dumps({'type': 'tool_end', 'name': event['name'], 'output': str(event['data'].get('output'))[:500]})}\n\n" # 图节点切换 elif kind == "on_chain_start" and event.get("name") in ["plan", "execute", "verify"]: yield f"data: {json.dumps({'type': 'node', 'name': event['name']})}\n\n" yield f"data: {json.dumps({'type': 'done'})}\n\n" return StreamingResponse( event_generator(), media_type="text/event-stream", headers={"Cache-Control": "no-cache", "X-Accel-Buffering": "no"} )这里有个坑要特别注意:X-Accel-Buffering: no这个 Header 必须加,否则如果前面有 Nginx 反向代理,SSE 会被缓冲,用户就看不到流式效果了。我第一次部署时没加这个,调试了半天才发现问题。
另外,MCP 工具的执行结果可能很大(比如读取一个大文件),直接塞进 SSE 事件会导致前端卡顿。我的做法是:工具结果超过 500 字符就截断,完整结果存到会话上下文里,前端需要时再单独请求。
4.5 并发处理与性能优化
商业级系统必须扛得住并发。我做过压测,单实例在 4 核 8G 的机器上,用异步方案能稳定支撑 50 个并发会话。关键优化点有三个。
第一,MCP 连接池化。每次请求都新建 MCP 连接开销很大。我用连接池复用 stdio 连接,SSE 连接则用 HTTP 长连接池。实测连接复用后,单次工具调用延迟从 200ms 降到 50ms 左右。
第二,LLM 调用批量化。对于独立的子任务,可以并行调用 LLM。LangGraph 支持并行节点,我用asyncio.gather把多个独立的代码分析任务并行化,整体耗时降低了约 40%。
第三,结果缓存。代码检索、文档查询这类只读操作的结果可以缓存。我用 Redis 做二级缓存,Key 是查询内容的哈希,TTL 设 1 小时。缓存命中率在重复任务场景下能达到 60% 以上。
import asyncio from functools import lru_cache import hashlib import redis.asyncio as redis redis_client = redis.from_url("redis://localhost:6379") async def cached_tool_call(tool_name: str, args: dict): """带缓存的工具调用""" cache_key = f"tool:{tool_name}:{hashlib.md5(str(args).encode()).hexdigest()}" # 尝试从缓存读取 cached = await redis_client.get(cache_key) if cached: return json.loads(cached) # 执行工具调用 result = await execute_tool(tool_name, args) # 只缓存只读操作的结果 if tool_name in READONLY_TOOLS: await redis_client.setex(cache_key, 3600, json.dumps(result)) return result注意:缓存只对只读工具有效。写操作绝对不能缓存,否则会导致状态不一致。我在
READONLY_TOOLS白名单里明确列出了可以缓存的工具,其他一律不走缓存。
5. 常见问题与排查技巧实录
5.1 MCP 连接失败的排查思路
这是最高频的问题。MCP Server 连不上,Agent 直接罢工。我整理了一套排查流程。
| 现象 | 可能原因 | 排查方法 | 解决方案 |
|---|---|---|---|
| stdio 连接超时 | Server 进程启动失败 | 手动运行 Server 脚本 | 检查依赖、路径、权限 |
| SSE 连接 404 | URL 路径错误 | curl 测试端点 | 确认 Server 的 SSE 路径 |
| 工具列表为空 | Server 未正确注册工具 | 查看 Server 日志 | 检查@app.list_tools装饰器 |
| 调用返回权限错误 | 路径/操作超出白名单 | 检查安全校验逻辑 | 调整白名单配置 |
| 间歇性连接断开 | 连接池配置不当 | 查看连接池指标 | 增大池大小、调整超时 |
我踩过最坑的一次是:Server 脚本在本地跑没问题,但通过 MCP Client 启动就失败。排查了半天,发现是 Client 启动 Server 时的工作目录和手动运行不一致,导致相对路径找不到文件。解决方案是在 Server 配置里显式指定cwd。
mcp_client = MultiServerMCPClient({ "filesystem": { "command": "python", "args": ["filesystem_server.py"], "transport": "stdio", "cwd": "/absolute/path/to/server" # 显式指定工作目录 } })5.2 LLM 不调用工具或调用错误工具
这个问题很常见,尤其是工具数量多的时候。LLM 可能忽略工具直接回答,或者选错工具。我的解决经验有三条。
第一,优化工具描述。工具描述要具体、有区分度。比如“读取文件”太笼统,改成“读取指定路径的文本文件内容,支持 UTF-8 编码,返回文件全文”就清晰很多。描述里要包含使用场景和限制条件。
第二,减少工具数量。一次暴露给 LLM 的工具不要超过 15 个。超过这个数,LLM 的选择准确率会明显下降。我的做法是按任务类型动态加载工具集,比如代码生成任务只加载文件操作和代码执行工具,不加载数据库工具。
第三,用 Few-shot 示例引导。在 System Prompt 里加几个工具调用的示例,能显著提升准确率。
SYSTEM_PROMPT = """你是一个编程助手,可以使用以下工具完成任务。 工具使用示例: 用户:帮我看看 main.py 里有什么 助手:[调用 read_file,参数 path="main.py"] 用户:在 utils.py 里加一个格式化日期的函数 助手:[调用 read_file 读取 utils.py] -> [调用 write_file 写入修改后的内容] 注意: - 修改文件前必须先读取文件内容 - 执行代码前先确认代码逻辑正确 - 遇到错误时先分析原因再重试 """5.3 代码执行沙箱的安全加固
代码执行工具是双刃剑。我在这上面踩过坑,有一次 Agent 生成的代码里有个死循环,把沙箱 CPU 跑满了。后来我做了几层加固。
资源限制:Docker 容器设置 CPU 和内存上限,--cpus=1 --memory=512m。执行超时设 30 秒,超时强制 kill。
网络隔离:沙箱默认无网络访问,需要联网的操作走单独的代理服务,且要白名单控制。
文件系统隔离:沙箱只挂载工作目录,且只读挂载系统目录。Agent 只能修改工作目录内的文件。
镜像最小化:沙箱镜像只装必要的运行时,不装编译器、不装包管理器,减少攻击面。
import docker import asyncio client = docker.from_env() async def execute_in_sandbox(code: str, language: str = "python") -> dict: """在沙箱中执行代码""" # 写入临时文件 with open("/tmp/sandbox_code.py", "w") as f: f.write(code) try: container = client.containers.run( image="code-sandbox:latest", command=f"timeout 30 python /workspace/code.py", volumes={"/tmp/sandbox_code.py": {"bind": "/workspace/code.py", "mode": "ro"}}, mem_limit="512m", cpu_period=100000, cpu_quota=100000, # 1 CPU network_disabled=True, detach=True, remove=True ) result = container.wait(timeout=35) logs = container.logs().decode("utf-8") return { "exit_code": result["StatusCode"], "output": logs[:5000], # 截断过长输出 "timeout": result["StatusCode"] == 124 } except Exception as e: return {"error": str(e)}提示:
network_disabled=True是关键。没有这个,Agent 生成的代码可能访问外部服务,造成数据泄露或滥用。如果确实需要联网(比如安装依赖),走单独的、有审计的代理通道。
5.4 成本控制的实战技巧
LLM 调用成本是商业级系统必须考虑的。我用过几个有效的控制手段。
模型分级:不是所有任务都需要最强模型。任务规划、代码生成用强模型,简单的格式检查、错误分类用轻量模型。我实测下来,分级后成本降低了约 50%,效果几乎无损。
Prompt 压缩:定期审查 System Prompt,删掉冗余内容。我见过一个项目的 System Prompt 有 3000 多 Token,压缩后只剩 800,效果一样。
结果缓存:前面提过的工具结果缓存,对成本控制贡献很大。特别是代码检索这类高频操作,缓存命中后直接省掉一次 LLM 调用。
Token 监控:用 LangChain 的 Callback 记录每次调用的 Token 消耗,按用户、按项目维度统计。发现异常消耗及时告警。
from langchain.callbacks.base import BaseCallbackHandler class TokenMonitor(BaseCallbackHandler): def __init__(self): self.total_tokens = 0 self.total_cost = 0.0 def on_llm_end(self, response, **kwargs): usage = response.llm_output.get("token_usage", {}) input_tokens = usage.get("prompt_tokens", 0) output_tokens = usage.get("completion_tokens", 0) # 按模型定价计算成本 cost = calculate_cost(response.llm_output.get("model_name"), input_tokens, output_tokens) self.total_tokens += input_tokens + output_tokens self.total_cost += cost # 上报到监控系统 report_metrics({ "tokens": input_tokens + output_tokens, "cost": cost, "model": response.llm_output.get("model_name") })这套监控上线后,我发现了一个有意思的现象:约 30% 的 LLM 调用是重复的或可缓存的。针对性地加了缓存后,月度成本直接降了四分之一。
5.5 会话状态丢失与恢复
LangGraph 的 Checkpoint 机制很强大,但配置不当会导致状态丢失。我遇到过几次会话中断后无法恢复的问题,排查后发现是 Checkpoint 的存储配置有问题。
关键点:thread_id必须稳定。每次请求都要用同一个thread_id,否则 LangGraph 会认为是新会话。我的做法是用用户 ID + 项目 ID 生成thread_id,保证同一用户在同一项目下的会话连续。
def get_thread_id(user_id: str, project_id: str) -> str: return f"user:{user_id}:project:{project_id}" # 恢复会话 config = {"configurable": {"thread_id": get_thread_id(user_id, project_id)}} state = await graph.aget_state(config) if state.values: # 会话存在,继续 async for event in graph.astream_events(input_data, config=config): ... else: # 新会话 async for event in graph.astream_events(input_data, config=config): ...另外,PostgreSQL Checkpoint 表需要定期清理,否则会无限增长。我写了个定时任务,删除 30 天前的 Checkpoint 记录。
6. 从 Demo 到生产的几个关键认知
聊到这里,技术细节基本覆盖了。最后分享几个我在实际项目中形成的认知,这些是文档里不会写的。
认知一:Agent 的可靠性不取决于模型,取决于工程。我见过太多团队把希望寄托在“换个更强的模型”上,但真正让系统稳定的是错误处理、重试机制、状态管理这些工程手段。模型能力是上限,工程能力是下限。商业级系统首先要保证下限。
认知二:工具的质量比数量重要。与其接 50 个半成品工具,不如把 10 个核心工具做到极致。工具的描述、参数校验、错误信息、返回格式,每一个细节都影响 LLM 的使用效果。我花在优化工具描述上的时间,比写 Agent 逻辑的时间还多。
认知三:可观测性是生命线。没有完善的日志、指标、追踪,出了问题根本无从下手。我在项目初期就集成了 LangSmith 做追踪,每次 Agent 执行都能看到完整的调用链、Token 消耗、耗时分布。这个投入在后期排查问题时回报巨大。
认知四:Human-in-the-loop 不是妥协,是特性。很多人觉得让 AI 自主完成任务才酷,但商业场景下,关键操作需要人工确认反而是优势。用户对 AI 的信任是逐步建立的,允许人工介入能显著提升用户接受度。
认知五:成本要一开始就控制。不要等到账单爆炸才想起来优化。模型分级、缓存、Prompt 压缩这些手段,应该在架构设计阶段就考虑进去。后期再改,成本高得多。
这套系统我在三个实际项目中落地过,最大的一个支撑了日均 5000+ 次编程任务,平均任务完成时间 3 分钟,用户满意度稳定在 90% 以上。当然,过程中踩的坑远不止文章里写的这些,但核心的方法论和关键技术点都在这里了。如果你正在做类似的事情,希望这些经验能帮你少走些弯路。