数据截至 (上游 commit 0de51d76e1dc)
Chat UI — MCP 工具循环(agent 部分)
这是整个项目里最“agent”的一章:模型不再只是说话,而是自己决定调用外部工具(搜网页、生图、查数据),拿到结果再继续。本章讲这套循环怎么转。
1. 它要解决的小问题
光会聊天的模型有两个硬伤:不知道实时信息、不能动手做事(生图、执行代码)。MCP(Model Context Protocol) 是一个标准协议,让外部“工具服务器”用统一方式暴露能力。Chat UI 要做的是:
- 去 MCP 服务器问有哪些工具,翻译成 OpenAI 的
tools格式; - 让模型在生成时自己挑工具、填参数;
- 执行这些工具,把结果喂回模型;
- 循环,直到模型不再要调工具、给出最终答案。
2. 顶层流程
runMcpFlow()
│
1. 收集 MCP 服务器(env 配置 + 请求里用户选的)
2. 安全过滤:只留公网 HTTPS(SSRF 防护)
3. 门控:模型支持工具吗?路由选的是工具型模型吗? ──否──▶ "not_applicable"(退回普通生成)
│ 是
4. getOpenAiToolsForMcp() ── 列出所有工具,转成 OpenAI tools 定义
│
└─▶ for loop (最多 10 轮):
├─ openai.chat.completions.create({ tools, tool_choice:"auto" }) 流式
├─ 边流边分拣:正文 token vs tool_call 增量
├─ 这轮有 tool_calls?
│ ├─ 是 → executeToolCalls() 并发跑工具 → 把结果作为 role:"tool" 消息追加 → continue 下一轮
│ └─ 否 → yield FinalAnswer,return "completed"
└─ (每个边界都检查中断)
关键文件:src/lib/server/textGeneration/mcp/runMcpFlow.ts:62。
3. 工具发现:把 MCP 工具变成 OpenAI tools
3.1 列举与缓存
getOpenAiToolsForMcp(servers)(mcp/tools.ts:249)对每个服务器:连上(先试 Streamable-HTTP,失败回退 SSE,tools.ts:190)→ client.listTools() → 缓存。
缓存的设计有讲究:按“单个服务器(url+headers)”缓存,而非整组(tools.ts:133 serverCacheKey)。原因写在注释里:headers 进 key 是因为“转发的用户 HF token 会让同一服务器对不同用户返回不同工具列表”;按服务器缓存则“切换一个服务器不会让其它服务器的缓存失效”。缓存有 TTL(60s)和上限(1000 条,LRU 淘汰)。
3.2 命名冲突处理
OpenAI 函数名有限制 ^[a-zA-Z0-9_-]{1,64}$。sanitizeName(tools.ts:58)把非法字符(包括 .,因为有些 provider 拒绝点号)换成 _ 并截断 64 字符。两个服务器有同名工具时,后者加服务器 名后缀去重(tools.ts:310),并维护一张 mapping: 函数名 → {server, tool} 表,执行时按它反查真正要调哪个服务器的哪个工具。
3.3 JSON Schema 规范化(这点很妙)
问题:某些 MCP 服务器吐的工具参数 schema 不规范(比如 hf.co/mcp 的 write_file 会给 {description, default: null} 但没有 type),而严格的 provider(如 Fireworks)会因为一个坏工具拒绝整个 tools 数组。
sanitizeJsonSchema(tools.ts:84)是个纯函数,递归修补:
- 丢掉矛盾的
default: null; - 给缺
type的节点推断类型:有properties等关键字 →object;有items→array;否则string; - 但空
{}不动——因为它表示“匹配任意值”(如hf_jobs.args这种任意 map),强加string会错误收窄。
这是“防御性兼容”的典型:为了让一个开放生态里千奇百怪的工具都能被严格 provider 接受。
4. 主循环:function-calling
循环体(runMcpFlow.ts:525,for (let loop = 0; loop < MAX_TOOL_ROUNDS; ...),MAX_TOOL_ROUNDS = 10)每轮:
a. 发起带工具的流式请求(runMcpFlow.ts:543),tool_choice: "auto" 让模型自己决定。
b. 边流边分拣(runMcpFlow.ts:573)。流里同时混着:
- 正文/推理 token → 累加并 yield
Stream(注意:只有在没看到 tool_call 时才流给用户,runMcpFlow.ts:679,避免把“调工具前的废话”显示出来); tool_calls增量 → 按index累积到toolCallState(名字和参数是分片流式来的,要拼)。
c. 这轮有工具调用吗?
- 有:把“带 tool_calls 的 assistant 消息” + 工具执行结果(
role:"tool")一起追加到消息列表,continue进下一轮——下一轮模型就能看到工具输出继续推理(runMcpFlow.ts:817)。 - 没有:yield
FinalAnswer,return "completed"(runMcpFlow.ts:903)。
一个鲁棒性补丁:有些 provider 流式 tool_call 缺 id。代码检测到后会发一个非流式请求重试,把带完整 id 的 tool_calls 找回来(runMcpFlow.ts:753)——因为后面 role:"tool" 消息必须用 tool_call_id 关联。
5. 工具执行:并发 + 按完成顺序回流
executeToolCalls(mcp/toolInvocation.ts:96)是个 async generator,把一批工具调用并发跑掉:
- 预发事件:每个调用先 yield
Tool/Call(显示“正在调用 X”)和Tool/ETA。 - 预连接客户端:对这批用到的每个不同服务器,提前从连接池拿 client(
toolInvocation.ts:174)。 - 并发执行:所有调用同时
callMcpTool(...),结果通过一个手写异步队列(createQueue,toolInvocation.ts:192)按完成先后流出Result/Progress/Error事件——谁先跑完谁先显示。 - 按原始顺序收集:最后把结果按调用顺序排好(
toolInvocation.ts:462),拼成role:"tool"消息。出错的工具也把错误文本喂回模型(toolInvocation.ts:474),免得模型“幻觉”成功了。
教学示意——并发跑、按完成顺序产出:
// 示意,非源码:并发执行但按"谁先完成"流式产出结果
const queue = createQueue();
const tasks = calls.map(async (call) => {
try {
const out = await callMcpTool(server, call.name, call.args); // 真正打 MCP 服务器
queue.push({ uuid: call.uuid, status: "ok", text: out.text });
} catch (e) {
queue.push({ uuid: call.uuid, status: "error", message: String(e) });
}
});
Promise.allSettled(tasks).then(() => queue.close()); // 全部结束后关队列
for await (const ev of queue.iterator()) yield ev; // 完成一个就 yield 一个
6. 工具提示词(给模型的使用说明)
光给 tools 定义不够,还要教模型何时/如何用。buildToolPreprompt(utils/toolPrompt.ts:3)拼一段系统提示,塞进 preprompt 最前面。要点:
- 列出可用工具名 + 当前日期时间(含用户时区);
- “能不用就不用”:写代码、创作、算数这类别调工具;
- 鼓励并行:相互独立的工具调用一次性全发;
- 搜索关键词、图片引用约定(用
image_1这种引用而非塞 base64)等。
7. 巧妙之处
- 按服务器(含 headers)缓存工具列表:既支持“每用户不同工具”,又让单个服务器开关不污染其它缓存(
tools.ts:133)。 - JSON Schema 防御性修补:一个坏工具不再连累整组 tools 被 provider 拒(
tools.ts:84)。 - 缺 id 自动非流式重补:兼容不规范 provider 的 tool_call 流(
runMcpFlow.ts:753)。 - 调工具前的正文不外显:
!sawToolCall才流 token,避免展示“我来搜一下”这种废话(runMcpFlow.ts:679)。 - 错误也喂回模型:防止模型幻觉工具成功(
toolInvocation.ts:474)。
8. 边界与局限
- 工具 follow-up 循环硬上限 10 轮(
MAX_TOOL_ROUNDS,runMcpFlow.ts:525),超了不再 fallback,而是发一段收尾答案并返回"exhausted"(runMcpFlow.ts:924),不会无限 agent。 - 工具只能是 MCP 工具;没有内置工具机制,一切外部能力都来自配置的 MCP 服务器。
- 路由型模型还要先过“选到工具型候选”这一关才会真跑工具(见第 4 章
resolveRouterTarget)。
9. 横向对比
相比 Letta / OpenHands 这类把工具循环做进“agent runtime”的项目,Chat UI 的工具循环是轻量、内联在生成里的:没有独立的 planner/记忆子系统,就是一个“completions ↔ 工具”的 10 轮循环。它的复杂度都花在兼容性(不同 provider 的 tool_call 怪癖、不同 MCP 服务器的 schema 怪癖)和产品体验(并发、ETA、进度、不外显废话)上——这正是“agent-ui”区别于“agent-runtime”的地方。
代码地图
| 主题 | 文件 | 符号 |
|---|---|---|
| 工具循环主体 | src/lib/server/textGeneration/mcp/runMcpFlow.ts | runMcpFlow |
| 工具发现/规范化 | src/lib/server/mcp/tools.ts | getOpenAiToolsForMcp, sanitizeJsonSchema, sanitizeName |
| 工具并发执 行 | src/lib/server/textGeneration/mcp/toolInvocation.ts | executeToolCalls, createQueue |
| 单次工具调用 | src/lib/server/mcp/httpClient.ts | callMcpTool |
| 工具提示词 | src/lib/server/textGeneration/utils/toolPrompt.ts | buildToolPreprompt |
| 服务器配置 | src/lib/server/mcp/registry.ts | getMcpServers, parseServers |