跳到主要内容

Chat UI — MCP 工具循环(agent 部分)

这是整个项目里最“agent”的一章:模型不再只是说话,而是自己决定调用外部工具(搜网页、生图、查数据),拿到结果再继续。本章讲这套循环怎么转。

1. 它要解决的小问题

光会聊天的模型有两个硬伤:不知道实时信息、不能动手做事(生图、执行代码)。MCP(Model Context Protocol) 是一个标准协议,让外部“工具服务器”用统一方式暴露能力。Chat UI 要做的是:

  1. 去 MCP 服务器问有哪些工具,翻译成 OpenAI 的 tools 格式;
  2. 让模型在生成时自己挑工具、填参数;
  3. 执行这些工具,把结果喂回模型;
  4. 循环,直到模型不再要调工具、给出最终答案。

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:45

3. 工具发现:把 MCP 工具变成 OpenAI tools

3.1 列举与缓存

getOpenAiToolsForMcp(servers)(mcp/tools.ts:215)对每个服务器:连上(先试 Streamable-HTTP,失败回退 SSE,tools.ts:158)→ client.listTools() → 缓存。

缓存的设计有讲究:按“单个服务器(url+headers)”缓存,而非整组(tools.ts:120 serverCacheKey)。原因写在注释里:headers 进 key 是因为“转发的用户 HF token 会让同一服务器对不同用户返回不同工具列表”;按服务器缓存则“切换一个服务器不会让其它服务器的缓存失效”。缓存有 TTL(60s)和上限(1000 条,LRU 淘汰)。

3.2 命名冲突处理

OpenAI 函数名有限制 ^[a-zA-Z0-9_-]{1,64}$sanitizeName(tools.ts:45)把非法字符(包括 .,因为有些 provider 拒绝点号)换成 _ 并截断 64 字符。两个服务器有同名工具时,后者加服务器名后缀去重(tools.ts:276),并维护一张 mapping: 函数名 → {server, tool} 表,执行时按它反查真正要调哪个服务器的哪个工具。

3.3 JSON Schema 规范化(这点很妙)

问题:某些 MCP 服务器吐的工具参数 schema 不规范(比如 hf.co/mcp 的 write_file 会给 {description, default: null}没有 type),而严格的 provider(如 Fireworks)会因为一个坏工具拒绝整个 tools 数组

sanitizeJsonSchema(tools.ts:71)是个纯函数,递归修补:

  • 丢掉矛盾的 default: null;
  • 给缺 type 的节点推断类型:有 properties 等关键字 → object;有 itemsarray;否则 string;
  • {} 不动——因为它表示“匹配任意值”(如 hf_jobs.args 这种任意 map),强加 string 会错误收窄。

这是“防御性兼容”的典型:为了让一个开放生态里千奇百怪的工具都能被严格 provider 接受。

4. 主循环:function-calling

循环体(runMcpFlow.ts:467,for (let loop = 0; loop < 10; ...))每轮:

a. 发起带工具的流式请求(runMcpFlow.ts:482),tool_choice: "auto" 让模型自己决定。

b. 边流边分拣(runMcpFlow.ts:511)。流里同时混着:

  • 正文/推理 token → 累加并 yield Stream(注意:只有在没看到 tool_call 时才流给用户,runMcpFlow.ts:598,避免把“调工具前的废话”显示出来);
  • tool_calls 增量 → 按 index 累积到 toolCallState(名字和参数是分片流式来的,要拼)。

c. 这轮有工具调用吗?

  • :把“带 tool_calls 的 assistant 消息” + 工具执行结果(role:"tool")一起追加到消息列表,continue 进下一轮——下一轮模型就能看到工具输出继续推理(runMcpFlow.ts:687)。
  • 没有:yield FinalAnswer,return "completed"(runMcpFlow.ts:747)。

一个鲁棒性补丁:有些 provider 流式 tool_call id。代码检测到后会发一个非流式请求重试,把带完整 id 的 tool_calls 找回来(runMcpFlow.ts:638)——因为后面 role:"tool" 消息必须用 tool_call_id 关联。

5. 工具执行:并发 + 按完成顺序回流

executeToolCalls(mcp/toolInvocation.ts:67)是个 async generator,把一批工具调用并发跑掉:

  1. 预发事件:每个调用先 yield Tool/Call(显示“正在调用 X”)和 Tool/ETA
  2. 预连接客户端:对这批用到的每个不同服务器,提前从连接池拿 client(toolInvocation.ts:130)。
  3. 并发执行:所有调用同时 callMcpTool(...),结果通过一个手写异步队列(createQueue,toolInvocation.ts:148)按完成先后流出 Result/Progress/Error 事件——谁先跑完谁先显示。
  4. 按原始顺序收集:最后把结果按调用顺序排好(toolInvocation.ts:333),拼成 role:"tool" 消息。出错的工具也把错误文本喂回模型(toolInvocation.ts:344),免得模型“幻觉”成功了。

教学示意——并发跑、按完成顺序产出:

// 示意,非源码:并发执行但按"谁先完成"流式产出结果
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:120)。
  • JSON Schema 防御性修补:一个坏工具不再连累整组 tools 被 provider 拒(tools.ts:71)。
  • 缺 id 自动非流式重补:兼容不规范 provider 的 tool_call 流(runMcpFlow.ts:638)。
  • 调工具前的正文不外显:!sawToolCall 才流 token,避免展示“我来搜一下”这种废话(runMcpFlow.ts:598)。
  • 错误也喂回模型:防止模型幻觉工具成功(toolInvocation.ts:344)。

8. 边界与局限

  • 工具 follow-up 循环硬上限 10 轮(runMcpFlow.ts:467),超了就 fallback,不会无限 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.tsrunMcpFlow
工具发现/规范化src/lib/server/mcp/tools.tsgetOpenAiToolsForMcp, sanitizeJsonSchema, sanitizeName
工具并发执行src/lib/server/textGeneration/mcp/toolInvocation.tsexecuteToolCalls, createQueue
单次工具调用src/lib/server/mcp/httpClient.tscallMcpTool
工具提示词src/lib/server/textGeneration/utils/toolPrompt.tsbuildToolPreprompt
服务器配置src/lib/server/mcp/registry.tsgetMcpServers, parseServers