数据截至 (上游 commit 0c84ae09499b)
决策循环:后端的大脑如何挑组件并流式吐 props
30 秒导读: 用户发来一句话,后端得决定「回什么文字、显示哪个 UI 组件、组件里填什么数据、还要不要调用工具」。这一章讲的
runDecisionLoop就是干这件事的核心算法——它把注册好的组件和工具喂给 LLM,然后一边接收 LLM 的流式输出、一边把还没吐完的 JSON 解析成组件 props,让前端能在模型还在打字时就先把组件画出来。
本章只讲后端的决策核心。上游「组件和工具怎么变成 LLM 能调用的东西」见 01-component-as-tool;下游「AG-UI 事件怎么累积成状态」见 03-agui-streaming;「HTTP 怎么传、客户端工具循环怎么转」见 04-message-lifecycle。
1. 先建直觉:决策循环要解决什么
假设你在一个天气 App 里问 AI:「北京今天天气怎么样?」后端手里有这些能力:一个能查天气的工具 get_weather,一个能把天气画在屏幕上的组件 Weather。
后端要替 LLM 把一连串决定串起来:
- 要 不要先调
get_weather拿数据? - 拿到数据后,要不要显示
Weather组件? - 显示的话,组件的
props(城市、温度、图标)填什么? - 同时回给用户什么文字?
决策循环 = 把「模型的一次流式输出」翻译成上面这几个决定的机器。 它的输入是聊天历史 + 一批工具,输出是一串随时间增长的「组件决策」(DecisionStreamItem)。
它的一句话精华在于流式:模型吐 props 的 JSON 是一个字符一个字符来的,决策循环不等它吐完,而是每来一小段就尝试解析一次,让前端能提前渲染出「正在成形」的组件。
2. 顶层全景(它大概怎么转)
怎么读这张图: 从左到右是一次 runDecisionLoop 调用的数据流;虚线框是「对每个流式 chunk 重复做」的循环体。
输入: 聊天历史 messages + 一批工具 strictTools
│
▼
┌───────────────────────────┐
│ ① 工具分流 + 加标准参数 │ UI工具(show_component_*) / 信息工具
│ + 组装 system prompt │ 每个工具补上 _tambo_statusMessage 等
└───────────────────────────┘
│
▼
┌───────────────────────────┐
│ ② LLMClient.complete │ stream:true,返回异步流
│ (流式,带 tool_choice) │
└───────────────────────────┘
│ 每来一个 chunk ↓ (循环体)
┌ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ┐
③ partial-json 解析 把「还没吐完」的 arguments 解析成
tool_call.arguments in-progress 的 props
│ │ │
▼
④ 组装 DecisionStreamItem decision(旧格式) + aguiEvents(新格式)
│ │ │
▼ yield
└ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ┘
│
▼
输出: AsyncIterator<DecisionStreamItem> 一串随时间增长的决策
各部件的一句话职责:
| 部件 | 干什么 | 在哪个文件 |
|---|---|---|
runDecisionLoop | 决策循环主函数,async function* 生成器 | services/decision-loop/decision-loop-service.ts:115 |
addParametersToTools / standardToolParameters | 给每个工具补上 Tambo 标准参数 | services/tool/tool-service.ts:148 / :21 |
generateDecisionLoopPrompt | 组装 system prompt(教模型区分工具类型) | prompt/decision-loop-prompts.ts:3 |
LLMClient.complete | 统一的 LLM 流式接口(多家提供方) | services/llm/llm-client.ts:62 |
DecisionStreamItem | 决策循环的产物:旧决策 + AG-UI 事件 | services/decision-loop/decision-loop-service.ts:40 |
3. 主线走一遍:runDecisionLoop 从头到尾
主函数是一个异步生成器 async function* runDecisionLoop(...),签名见 decision-loop-service.ts:115。它拿到 llmClient、messages、strictTools 等参数,分三个阶段:准备 → 发起流式请求 → 边流边解析并 yield。
3.1 准备阶段:分流、加参数、组 prompt
第一步,把工具分成两类。 决策循环只需要挑出「UI 工具」(以 show_component_ 开头的),因为后面判断「模型这次是不是要显示组件」全靠它:
// decision-loop-service.ts:126 —— 真实源码
const componentTools = strictTools.filter((tool) =>
isUiToolName(getToolName(tool)),
);
isUiToolName 就是判断名字是否以 UI_TOOLNAME_PREFIX(即 "show_component_")开头(core/src/ui-tools.ts:1)。注意分流不改动 strictTools 本身,componentTools 只是一个「哪些是 UI 工具」的备查子集;其余的全算「信息工具」。
第二步,给所有工具补上标准参数。 见 decision-loop-service.ts:130 调 addParametersToTools(strictTools, standardToolParameters)——这一步是 Tambo 的一个巧妙设计,§5.2 细讲。
第三步,组装 system prompt。 generateDecisionLoopPrompt(customInstructions, memories) 返回一个带占位符的模板,再用 formatTemplate 把 {user_memories} 之类的变量提前填进去(:145-157)。
一个容易忽略的坑(源码注释点破): 变量替换必须在 prompt 变成 messages 之前做完。因为用户消息里可能带任意
{花括号},如果延后到统一模板处理,会把用户消息里的花括号误当成模板变量(:150-153)。
准备阶段收尾:把 system prompt 包成一条合成的 MessageRole.System 消息(id 写死为 "synthetic-system-message-id",因为它不入库),拼到聊天历史前面,得到 promptMessages(:175-189)。
3.2 发起流式请求
// decision-loop-service.ts:191 —— 真实源码(节选)
const responseStream = await llmClient.complete({
messages: promptMessages,
tools: toolsWithStandardParameters,
stream: true,
tool_choice: convertToolChoice(forceToolChoice),
abortSignal,
providerSkills,
});
关键是 stream: true —— 返回的是一个 AsyncIterableIterator<LLMStreamItem>,能一段段拿。tool_choice 由 convertToolChoice 把上层传入的 forceToolChoice 翻译成 OpenAI 的取值(§5.4)。
3.3 循环体:边流边解析并 yield
主循环 for await (const streamItem of responseStream)(:215)对每个 chunk做同一件事,并且用一个 accumulatedDecision 把结果累积起来——后面来的 chunk 覆盖/补全前面的字段(:296-299)。整个循环体包在 try/catch 里:单个 chunk 解析失败只打日志、不中断整条流(:306-308)。
循环体的核心逻 辑就是 §4 要拆开讲的四小步。
4. 循环体拆解:一个 chunk 里发生了什么
每个 chunk 到手,决策循环依次做四件事。先看整体,再逐个细讲。
| 步 | 做什么 | 关键调用 |
|---|---|---|
| a | 取出这轮的文本和(至多一个)工具调用 | getLLMResponseMessage / tool_calls?.[0] |
| b | 用 partial-json 解析没吐完的 arguments | parse(toolCall.function.arguments) |
| c | 抽出状态消息、过滤掉标准参数、判断是不是 UI 工具 | filterOutStandardToolParameters |
| d | 组装 DecisionStreamItem 并 yield | buildToolCallRequest / extractComponentIdFromEvents |
4.a 取文本与工具调用
// decision-loop-service.ts:217 —— 真实源码(节选)
const message = getLLMResponseMessage(llmResponse);
const toolCall = llmResponse.message?.tool_calls?.[0];
注意 tool_calls?.[0] —— 决策循环只看第一个工具调用。这呼应了 prompt 里明确要求的「不要并行调工具」(decision-loop-prompts.ts:17)。
4.b partial-json:边流边吐 props(本章核心)
这是整章最妙的一处。模型吐 arguments 时,JSON 是逐字符流进来的,某个 chunk 里它可能长这样(还没闭合):
{"_tambo_statusMessage":"正在查天气","city":"北京","temp
标准 JSON.parse 遇到这种残缺串会直接抛错。Tambo 改用 partial-json 库的 parse,它能容忍未闭合的 JSON,尽力返回已经成形的部分:
// decision-loop-service.ts:228 —— 真实源码
let toolArgs: Partial<TamboToolParameters> = {};
if (toolCall?.type === "function") {
try {
//partial parse tool params to allow streaming in-progress params
toolArgs = parse(toolCall.function.arguments);
} catch (_e) {
// Ignore parse errors for incomplete JSON
}
}
为什么这样就够了? 因为上游的 AISdkClient 把工具参数是累加着往外发的(accumulatedToolCall.arguments += delta.delta,services/llm/ai-sdk-client.ts:656)。所以每个 chunk 里的 arguments 都是「到目前为止的完整前缀」,partial-json 对这个前缀尽力解析,就得到了逐渐补全的 props。
下面用一段示意代码演示这个「前缀 → 逐步成形对象」的效果:
// 示意,非源码 —— 演示 partial-json 对同一个渐长前缀的解析结果
import { parse } from "partial-json";
parse('{"city":"北京","tem'); // → { city: "北京" } 还没到 temp
parse('{"city":"北京","temp":2'); // → { city: "北京", temp: 2 } temp 出来了
parse('{"city":"北京","temp":23}'); // → { city: "北京", temp: 23 } 完整
重点看: 前端因此能在模型「还在打字」时就拿到 { city: "北京" } 先把组件框架画出来,等 temp 到了再填温度——这就是 Tambo「组件像在被实时填充」体验的底层来源。
4.c 抽状态消息、过滤标准参数、判 UI 工具
从解析出的 toolArgs 里先取出两个「状态消息」字段(它们是 Tambo 注入的,不是业务参数):
// decision-loop-service.ts:237 —— 真实源码
const statusMessage = toolArgs._tambo_statusMessage;
const completionStatusMessage = toolArgs._tambo_completionStatusMessage;
然后把这些 Tambo 私有参数从要交给业务的 props 里剔掉,用 filterOutStandardToolParameters(:241-256,原理见 §5.2)。
再判断这次工具调用是不是 UI 工 具——即它的名字是否命中前面备好的 componentTools:
// decision-loop-service.ts:222 —— 真实源码
const isUITool =
toolCall?.type === "function" &&
componentTools.some(
(tool) => getToolName(tool) === toolCall.function.name,
);
isUITool 是后面所有分支的开关:是 UI 工具,才会有 componentName 和 props;否则 props 为 null(:279-285)。组件名就是把 show_component_ 前缀切掉:toolCall.function.name.slice(UI_TOOLNAME_PREFIX.length)(:280)。
4.d 组装 DecisionStreamItem 并 yield
最后把这一轮的所有信息拼成 parsedChunk,并入 accumulatedDecision,再 yield 出一个 DecisionStreamItem。这个产物同时装两套东西:
// decision-loop-service.ts:40 —— 真实源码(接口定义,节选)
export interface DecisionStreamItem {
/** 传统组件决策对象(向后兼容) */
decision: LegacyComponentDecision;
/** 从当前流式 delta 生成的 AG-UI 事件(V1 API 流式用) */
aguiEvents: BaseEvent[];
/** 要持久化到工具调用上的 provider 选项(如 Gemini thought signatures) */
toolCallProviderOptionsById?: Record<string, ProviderOptions>;
}
decision(LegacyComponentDecision):老 API 用的扁平结构——message/componentName/props/toolCallRequest/statusMessage…aguiEvents:新的 AG-UI 事件流,streamItem.aguiEvents原样透传(累积逻辑不在这层,见 03-agui-streaming)。
这就是决策循环的「双产物」设计:同一次流式解析,既喂饱老客户端,又喂饱新协议(§5.4 再讲三个辅助函数)。
5. 核心机制细讲
5.1 工具二分:UI 工具 vs 信息工具
Tambo 把工具分两类,并把这个区分直接写进 system prompt 教给模型(decision-loop-prompts.ts:13-19):
| 类型 | 名字特征 | 作用 | 模型该怎么用 |
|---|---|---|---|
| UI 工具 | 以 show_component_ 开头 | 在用户屏幕上显示一个组件 | 可以连续调多个来显示多个组件 |
| 信息工具 | 其它所有工具 | 取数据 / 执行动作 | 可连续调用来收集数据,但不要并行 |
prompt 里甚至给了个天气+交通的具体例子,教模型「先 get_weather 拿数据、再 show_component_Weather 把数据传给组件」(decision-loop-prompts.ts:21-29)。prompt 还专门讲了两个进阶概念:
<component_state>:用户和组件交互后,系统会把当前组件状态(JSON)附在助手消息上,让模型基于「屏幕现状」做决定,但绝不能把这个标签回显给用户(:30-41)。- Interactable Components(可交互组件):屏幕上放着的、模型可以去改其 props/state 的组件,每个带
id/isSelected,被选中的要优先处理(:42-52)。
代码侧的分流只有一行 filter(§3.1),但语义上的分工是靠 prompt 教会模型的——这是「代码 + prompt 协同」的典型例子。
5.2 标准参数注入:让每个工具都会「报状态」
Tambo 想要一个体验:调工具时屏幕上能显示「正在查天气…」,调完变成「已查到天气」。它的实现不是在业务工具里塞字段,而是给每一个工具的参数表都强行补两个标准参数。
参数的定义(tool-service.ts:21):
| 参数名 | 含义 | 例子 |
|---|---|---|
_tambo_statusMessage | 工具正在做什么(动词开头) | "looking for …" |
_tambo_completionStatusMessage | 工具做完了什么(替换上一个) | "looked for …" |
注入靠 addParametersToTools,它把标准参数 merge 进每个工具的 properties,并加进 required:
// tool-service.ts:148 —— 真实源码(节选)
properties: {
...(parameters.properties || {}), // 先铺标准参数
...(tool.function.parameters?.properties || {}), // 业务参数覆盖同名
},
required: Array.from(new Set([...业务 required, ...标准 required])),
因为它们被列进 required,模型每次调工具都必须填这两句状态文案——于是「进度提示」这个横切功能,不需要每个业务工具各写一遍,靠一次注入就全覆盖了。
注入之后要能拆回去。 模型返回的 args 里混着这俩私有参数,交给业务前必须剔除。filterOutStandardToolParameters 的做法很干净:它不维护一张黑名单,而是只保留「原始工具 schema 里本来就声明过」的参数名(tool-service.ts:186):
// tool-service.ts:207 —— 真实源码(节选)
return Object.entries(parsedArguments)
.filter(([name]) => definedParamNames.includes(name)) // 只留原 schema 有的
.map(([parameterName, parameterValue]) => ({ parameterName, parameterValue }));
注意它查的是原始 strictTools(没加标准参数那份),所以 _tambo_* 自然被过滤掉。决策循环里对 props(:241-256)和 toolCallRequest(buildToolCallRequest,:353)都走这一层过滤。
5.3 tool_choice、toolCallRequest、componentId:三个辅助函数
循环体里还有三个小而关键的辅助函数,各管一件事:
① convertToolChoice —— 把上层的 forceToolChoice 字符串翻成 OpenAI 的 tool_choice 取值(decision-loop-service.ts:75):
// decision-loop-service.ts:82 —— 真实源码(节选)
if (forceToolChoice === undefined) return "auto"; // 默认自由决定
if (isToolChoiceKeyword(forceToolChoice)) return forceToolChoice; // auto/required/none 直通
return { type: "function", function: { name: forceToolChoice } }; // 指定工具名 → 强制调它
配套还有一处前置校验:若强制指定了一个工具名、但它不在工具表里,直接抛错(:135-143)——这是 fail-fast,不给模型偷偷降级的机会。
② buildToolCallRequest —— 无论 UI 还是信息工具,都把这次调用打包成给客户端执行的 ToolCallRequest(toolName + 过滤后的 parameters)。它同样用 partial-json 解析,所以调用请求也能在参数没吐完时就先构造出来(:317-367)。
③ extractComponentIdFromEvents —— 组件的 componentId 是在流式过程中生成、藏在一个 tambo.component.start 自定义 AG-UI 事件里的。这个函数把它从事件里捞出来(:379-395)。因为 start 事件只在第一个 delta 发一次,所以决策循环会保留累积的 componentId、后续 chunk 找不到新的就沿用老的(:283)。
5.4 一句关于 LLM 提供方的话
决策循环只依赖抽 象接口 LLMClient(llm-client.ts:62),真正的多提供方实现是 AISdkClient(services/llm/ai-sdk-client.ts:102)——它基于 Vercel AI SDK,按 provider 分派到 OpenAI / Anthropic / Mistral / Gemini / Groq / Cerebras / openai-compatible(ai-sdk-client.ts:399-415)。对本章而言,只需知道:决策循环拿到的是一个「统一的流式接口」,底层是谁不影响这一层的算法。 流式协议细节见 03-agui-streaming。
6. prompt 是怎么组装的(以及一个诚实的澄清)
system prompt 由 generateDecisionLoopPrompt 用 createPromptTemplate 生成一个「模板串 + 变量表」(decision-loop-prompts.ts:3)。模板里预留了几个占位符,由变量表按条件填充:
| 占位符 | 填什么 | 条件 |
|---|---|---|
{user_memories} | 跨会话记忆,包在 <memory_data> 里 | 传了 memories 才填 |
{custom_instructions} | 开发者的额外指令 | 传了 customInstructions 才填 |
{interactables_example} | 可交互组件的 JSON 示例 | 固定填 |
{context_attachments_example} | 上下文附件的 JSON 示例 | 固定填 |
关于 component-formatting.ts 的诚实说明。 这个文件里有一套把组件格式化成文本块的函数(formatComponent :92、generateAvailableComponentsList :106),会把每个组件渲染成 componentName / description / props(JSON Schema) 的列表。
但核对全仓后:这些导出只被它自己的 component-formatting.test.ts 引用,没有接进当前的决策循环路径(inferred:基于对 packages、apps 全仓 grep 的结果)。当前 Tambo 让组件抵达模型的方式不是把它们写进 prompt 文本,而是把每个组件转成一个 show_component_* UI 工具(convertComponentsToUITools,tool-service.ts:100;上游由 getToolsFromSources 组装,:215)。也就是说,「组件即工具」这条路(见 01-component-as-tool)取代了「组件写进 prompt」。所以 component-formatting.ts 在这一版里更像是一份未接线的/备用的格式化器,而非决策循环的活跃组成部分。
7. 两种后端形态:纯 LLM vs 外接 agent 框架
runDecisionLoop 是「纯 LLM 决策」这一种形态。Tambo 还支持把决策权交给外部 agent 框架(Mastra / CrewAI / LangGraph / LlamaIndex / PydanticAI / 通用 AG-UI),对应另一个更简单的循环 runAgentLoop(services/decision-loop/agent-loop.ts:36)。
两者由 createTamboBackend 按 aiProviderType 二选一(tambo-backend.ts:81)。选择逻辑在 AgenticTamboBackend.create 的 switch 里(:141-167),而 runDecisionLoop 方法则按「有没有 agentClient」分派(:200-224):
createTamboBackend(options.aiProviderType)
│
┌────┴─────────────────────────┐
▼ ▼
AiProviderType.LLM AiProviderType.AGENT
│ │ (需要 agentType + agentUrl,缺了就抛错)
▼ ▼
runDecisionLoop runAgentLoop
(自己组 prompt / 分流 / (把 messages+tools 交给外部 agent,
partial-json 解析) for-await 它回吐的 AG-UI 消息)
两个循环产出的是同一种类型 DecisionStreamItem(agent-loop.ts 直接 re-export 了它,:18),所以对上层调用者透明。差别在于:
| 维度 | runDecisionLoop(纯 LLM) | runAgentLoop(外接 agent) |
|---|---|---|
| 决策者 | Tambo 自己(组 prompt + 分流 + 解析) | 外部 agent 框架 |
| 工具二分 / 标准参数 | 有 | 无(直接把 strictTools 交出去) |
| props 增量解析 | 有(partial-json) | 无;args 用普通 JSON.parse(agent-loop.ts:130) |
aguiEvents | 逐 chunk 透传真实事件 | 目前发空数组(注释说明将来再接,:70-90) |
componentName | 从工具名切出 | 恒为 null(:81) |
一句话:runAgentLoop 是把「挑组件、填 props」的活儿外包出去了,自己只负责把外部 agent 回来的消息翻译成 DecisionStreamItem,所以它拿不到组件决策(componentName: null)。
AISdkClient 在两种形态里都会被构造(tambo-backend.ts:123),但 AGENT 形态实际驱动的是 AgentClient。
8. 边界与坑
- 只处理第一个工具调用。
tool_calls?.[0](:219),并行工具调用不被支持,靠 prompt 明确禁止(decision-loop-prompts.ts:17)。 - 单 chunk 解析错误被吞。 循环体
try/catch只console.error、继续下一个 chunk(:306-308);好处是流不会因一个坏 chunk 中断,代价是错误不会向上冒泡。 buildToolCallRequest只支持function类型工具;遇到custom类型工具调用会console.warn并返回undefined(:322-328)。- 空消息 / 缺 threadId 直接抛错。 fail-fast,不给默认值(
:166-172)。 component-formatting.ts未接线(见 §6),不要以为组件是通过它进 prompt 的。runAgentLoop的aguiEvents目前是空的(:70-90),外接 agent 形态下拿不到细粒度的 AG-UI 事件流。
9. 代码地图(导航索引)
| 主题 | 文件 | 符号名 |
|---|---|---|
| 决策循环主函数 | packages/backend/src/services/decision-loop/decision-loop-service.ts | runDecisionLoop |
| 双产物结构 | packages/backend/src/services/decision-loop/decision-loop-service.ts | DecisionStreamItem |
| tool_choice 转换 | packages/backend/src/services/decision-loop/decision-loop-service.ts | convertToolChoice |
| 构造工具调用请求 | packages/backend/src/services/decision-loop/decision-loop-service.ts | buildToolCallRequest |
| 从事件抽 componentId | packages/backend/src/services/decision-loop/decision-loop-service.ts | extractComponentIdFromEvents |
| 标准参数定义 | packages/backend/src/services/tool/tool-service.ts | standardToolParameters / TamboToolParameters |
| 注入标准参数 | packages/backend/src/services/tool/tool-service.ts | addParametersToTools |
| 过滤标准参数 | packages/backend/src/services/tool/tool-service.ts | filterOutStandardToolParameters |
| 组件转 UI 工具 | packages/backend/src/services/tool/tool-service.ts | convertComponentsToUITools / getToolsFromSources |
| system prompt 模板 | packages/backend/src/prompt/decision-loop-prompts.ts | generateDecisionLoopPrompt |
| 组件格式化(未接线) | packages/backend/src/prompt/component-formatting.ts | formatComponent / generateAvailableComponentsList |
| 外接 agent 循环 | packages/backend/src/services/decision-loop/agent-loop.ts | runAgentLoop |
| 后端工厂 / 形态选择 | packages/backend/src/tambo-backend.ts | createTamboBackend / AgenticTamboBackend.create |
| LLM 统一接口 | packages/backend/src/services/llm/llm-client.ts | LLMClient / LLMStreamItem |
| 多提供方实现 | packages/backend/src/services/llm/ai-sdk-client.ts | AISdkClient |
| UI 工具名判定 | packages/core/src/ui-tools.ts | isUiToolName / UI_TOOLNAME_PREFIX |