跳到主要内容

决策循环:后端的大脑如何挑组件并流式吐 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。它拿到 llmClientmessagesstrictTools 等参数,分三个阶段:准备 → 发起流式请求 → 边流边解析并 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:130addParametersToTools(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_choiceconvertToolChoice 把上层传入的 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 解析没吐完的 argumentsparse(toolCall.function.arguments)
c抽出状态消息、过滤掉标准参数、判断是不是 UI 工具filterOutStandardToolParameters
d组装 DecisionStreamItemyieldbuildToolCallRequest / 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 工具,才会有 componentNameprops;否则 propsnull(: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 由 generateDecisionLoopPromptcreatePromptTemplate 生成一个「模板串 + 变量表」(decision-loop-prompts.ts:3)。模板里预留了几个占位符,由变量表按条件填充:

占位符填什么条件
{user_memories}跨会话记忆,包在 <memory_data>传了 memories 才填
{custom_instructions}开发者的额外指令传了 customInstructions 才填
{interactables_example}可交互组件的 JSON 示例固定填
{context_attachments_example}上下文附件的 JSON 示例固定填

关于 component-formatting.ts 的诚实说明。 这个文件里有一套把组件格式化成文本块的函数(formatComponent :92generateAvailableComponentsList :106),会把每个组件渲染成 componentName / description / props(JSON Schema) 的列表。

核对全仓后:这些导出只被它自己的 component-formatting.test.ts 引用,没有接进当前的决策循环路径(inferred:基于对 packagesapps 全仓 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)。

两者由 createTamboBackendaiProviderType 二选一(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/catchconsole.error、继续下一个 chunk(:306-308);好处是流不会因一个坏 chunk 中断,代价是错误不会向上冒泡。
  • buildToolCallRequest 只支持 function 类型工具;遇到 custom 类型工具调用会 console.warn 并返回 undefined(:322-328)。
  • 空消息 / 缺 threadId 直接抛错。 fail-fast,不给默认值(:166-172)。
  • component-formatting.ts 未接线(见 §6),不要以为组件是通过它进 prompt 的。
  • runAgentLoopaguiEvents 目前是空的(:70-90),外接 agent 形态下拿不到细粒度的 AG-UI 事件流。

9. 代码地图(导航索引)

主题文件符号名
决策循环主函数packages/backend/src/services/decision-loop/decision-loop-service.tsrunDecisionLoop
双产物结构packages/backend/src/services/decision-loop/decision-loop-service.tsDecisionStreamItem
tool_choice 转换packages/backend/src/services/decision-loop/decision-loop-service.tsconvertToolChoice
构造工具调用请求packages/backend/src/services/decision-loop/decision-loop-service.tsbuildToolCallRequest
从事件抽 componentIdpackages/backend/src/services/decision-loop/decision-loop-service.tsextractComponentIdFromEvents
标准参数定义packages/backend/src/services/tool/tool-service.tsstandardToolParameters / TamboToolParameters
注入标准参数packages/backend/src/services/tool/tool-service.tsaddParametersToTools
过滤标准参数packages/backend/src/services/tool/tool-service.tsfilterOutStandardToolParameters
组件转 UI 工具packages/backend/src/services/tool/tool-service.tsconvertComponentsToUITools / getToolsFromSources
system prompt 模板packages/backend/src/prompt/decision-loop-prompts.tsgenerateDecisionLoopPrompt
组件格式化(未接线)packages/backend/src/prompt/component-formatting.tsformatComponent / generateAvailableComponentsList
外接 agent 循环packages/backend/src/services/decision-loop/agent-loop.tsrunAgentLoop
后端工厂 / 形态选择packages/backend/src/tambo-backend.tscreateTamboBackend / AgenticTamboBackend.create
LLM 统一接口packages/backend/src/services/llm/llm-client.tsLLMClient / LLMStreamItem
多提供方实现packages/backend/src/services/llm/ai-sdk-client.tsAISdkClient
UI 工具名判定packages/core/src/ui-tools.tsisUiToolName / UI_TOOLNAME_PREFIX