数据截至 (上游 commit 0c84ae09499b)
一次消息的一生:HTTP 传输 + 客户端工具循环
30 秒导读: 你在前端调一次
sendMessage,Tambo 会把「你注册的组件和工具」连同消息一起 POST 给后端,后端用 SSE(Server-Sent Events,服务器单向持续推事件的长连接) 把 AG-UI 事件一条条吐回来。如果模型想调用一个只有浏览器/你的 app 才能执行的工具(查本地状态、点某个 按钮、调你自己的 API),后端不会自己跑,而是发一条tambo.run.awaiting_input把这次 run 暂停; 前端就地把工具跑了,把结果当作下一条消息、带上previousRunId再发一次 run,后端接着往下想。 这样「AI → 工具 → AI → 工具 → …」就被缝成一条连续对话。本章端到端串这一趟。
本章聚焦传输层与客户端工具循环这条主线。三件事刻意不在这里展开,请看兄弟章:
- 事件累加器 / reducer 内部怎么把碎片拼成消息 —— 见 第 3 章:流式协议。
- 后端「大脑」怎么挑组件、怎么流式吐 props —— 见 第 2 章:决策循环。
- 前端拿到流式 props 后怎么渲染成活组件 —— 见 第 5 章:前端渲染。
- 组件/工具是怎么变成「LLM 能调用的东西」的 —— 见 第 1 章:注册模型。
1. 这是什么(零基础也能懂)
一句话定义: 「一次消息的一生」= 从前端 client.run() 发出,到后端流式回一堆事件、
中途可能停下来让前端执行本地工具、再续跑,直到 RUN_FINISHED 的完整往返过程。
它要解决的核心矛盾: 模型跑在后端,但很多工具的「手脚」长在前端。
- 后端能直接跑的:MCP 服务器工具、后端自己的系统工具 —— 它就地调完,继续往下。
- 后端跑不了的:读浏览器
localStorage、弹一个确认框、调你 app 里带用户登录态的私有 API —— 这些只有前端有执行环境。
Tambo 的答案是暂停 + 续跑:后端把这类工具调用「挂起」,通过 SSE 告诉前端「我需要你去执行这些」,
前端执行完把结果回传,后端接着想。对使用者来说,这一切藏在一个 sendMessage 调用后面。
用起来什么样(最小示意):
// 示意,非源码:注册一个只有前端能跑的工具,然后发一 条消息
client.registerTool({
name: "getSelectedRow",
description: "返回用户当前在表格里选中的那一行",
tool: async () => window.__grid.getSelection(), // 只有浏览器有这个
});
// 一次 run:内部可能经历「模型想调 getSelectedRow → 前端执行 → 模型接着答」
const stream = client.run("把我选中的那行导出成 CSV");
for await (const { snapshot } of stream) {
render(snapshot.messages); // 边流边渲染
}
一句话直觉: 把一次 run 想成一通没挂断的电话。后端一直在说(SSE 长连接),偶尔说
「你那边查一下 X」然后等你回话(awaiting_input);你查完报数字(tool result),它接着说。
电话从头到尾是同一通——靠 previousRunId 把前后两段接上。
2. 顶层全景(它大概怎么转)
怎么读这张图: 从上往下是时间。左边是前端(浏览器 / 你的 app),右边是后端(Tambo Cloud 或你自托管的同一套 NestJS API)。中间的双箭头是 HTTP。虚线框 = 一次「工具循环」,可以转 0 到多圈。
前端 @tambo-ai/client 后端 apps/api (NestJS)
───────────────────── ─────────────────────
client.run(msg)
│ 把注册表转成
│ AvailableComponents / Tools
▼
createRunStream() ──POST /v1/threads[/:id]/runs──▶ V1Controller
│ startRun(抢并发锁)
│ executeRun(开 SSE)
◀═══════════ SSE: RUN_STARTED, TEXT_*, TOOL_CALL_* ══╡ advanceThread() 流式
│ handleEventStream │ 顺流吐 AG-UI 事件
│ → reducer 累积(第3章) │
│ │ 冒出「客户端工具」调用?
◀═══════════ SSE: tambo.run.awaiting_input ══════════╡ 是 → 发暂停事件,收尾本段流
│ │
┌──┴─ 本地执行工具 executeAllPendingTools ──────────────────────────────┐ 工具循环
│ │ 把结果打包成 tool_result 内容 │(0..N 圈)
│ ▼ │
│ executeToolsAndContinue() ──POST /v1/threads/:id/runs(带 previousRunId)▶ 又一次 run │
└──◀════════════ SSE: 接着 TEXT_* / 再一次 awaiting_input… ══════════════┘
│
◀═══════════ SSE: RUN_FINISHED ══════════════════════
▼
stream.thread 兑现最终 thread 快照
部件一句话职责:
| 部件 | 干什么 | 在哪个文件 |
|---|---|---|
client.run() | 对外入口,建一个 TamboStream 立即返回,后台起处理循环 | packages/client/src/tambo-client.ts:222 (run) |
TamboStream.processLoop | 端到端主循环:收流→分发→检测暂停→执行工具→续跑 | packages/client/src/tambo-stream.ts:243 (processLoop) |
createRunStream | 把注册表转成 API 格式,选对 HTTP 方法发出去 | packages/client/src/utils/send-message.ts:229 (createRunStream) |
V1Controller | NestJS 路由,开 SSE 响应头、挂连接关闭钩子 | apps/api/src/v1/v1.controller.ts:342 (createThreadWithRun)、:471 (createRun) |
V1Service.executeRun | 驱动 advanceThread,把内部事件 顺流写成 SSE | apps/api/src/v1/v1.service.ts:672 (executeRun) |
ClientToolCallTracker | 后端侧盯流,判断有没有「挂起的客户端工具」 | apps/api/src/v1/v1-client-tools.ts:54 (ClientToolCallTracker) |
executeAllPendingTools | 前端侧本地把工具跑了,产出 tool_result | packages/client/src/utils/tool-executor.ts:161 |
ToolCallTracker | 前端侧累积工具参数、按 id 取回待执行的调用 | packages/client/src/utils/tool-call-tracker.ts:53 |
主线走一遍(高层): 发消息 → 后端开 SSE、流式生成 → 遇客户端工具就发 awaiting_input 暂停 →
前端执行、回传结果、带 previousRunId 续跑 → 无更多工具则 RUN_FINISHED → stream.thread 兑现。
3. 前端发起:从注册表到一条 HTTP 请求
这节讲什么: client.run() 之后、HTTP 真正发出之前,前端做了哪些事。