- AI 应用
- 后端
【免费下载链接】botpress
The open-source hub to build & deploy GPT/LLM Agents ⚡️
本指南以 Botpress 开源仓库中 LLMz 框架的官方示例 22_chat_streaming(Streaming Chat — guided simulation) 为蓝本,完整讲解如何把一个 LLM Agent 的"一切产物"——逐 token 的消息体、LLM 生成的代码、工具调用过程、类型化退出与每轮统计——实时渲染到客户端。读完本文,你将掌握Chat.onMessageDelta与onTrace两大核心钩子的正确用法,理解 LLMz"代码优先"模式下链式工具调用的实现思路,并能把同样的模式迁移到 WebSocket / SSE 前端或其他任意传输层。
示例概览:一个"太空旅行社"引导式模拟
22_chat_streaming位于 packages/llmz/examples/22_chat_streaming,是一个交互式的"太空旅行社"(Space Travel Agency)Agent。它把执行过程中产生的一切信息实时渲染在终端上——这里的"客户端"是终端,但注释明确说明:同样的模式完全适用于 websocket、SSE 响应或任何其他传输层。
示例完整演示了以下五类能力:
- 流式消息体(Streamed messages):
Chat.onMessageDelta在 LLM 仍在生成时,随每个 body 分片触发; - 生成的代码(Generated code):
onTrace钩子收到携带 LLM 所写代码的llm_call_successtrace,代码在运行前被渲染在方框中; - 实时工具调用(Live tool calls):
tool_calltrace 在调用完成时以内联⚙ tool(input) → output (ms)形式写入会话记录; - 链式工具(Chained tools):预订是两步流程——
bookTrip只做预订,processPayment必须支付该预订才能拿到booked退出所要求的确认 ID,Agent 在单个生成代码块内完成两者(外加checkAvailability)的链式调用; - 类型化退出(Typed exits):
booked与cancelled携带 zui schema,result.is(exit)将载荷收窄为类型化数据,内建的ListenExit维持对话循环。
运行前准备与启动方式
环境要求
流式渲染要求Cognitive v2(beta)客户端。示例源码 index.ts 中明确注释了这一点:
// Streaming requires the Cognitive v2 (beta) client. A regular Botpress // client also works, but messages are then delivered whole, not streamed. const client = new Cognitive({ botId: process.env.BOTPRESS_BOT_ID!, token: process.env.BOTPRESS_TOKEN!, })普通 Botpress 客户端同样可以运行该示例,但消息将以完整消息形式一次性交付,而不是逐 token 流式到达。运行时在 runtime/execute.ts 中会检测客户端类型:当传入的 client 是Cognitive或自定义模型客户端时直接使用,否则会自动包装为Cognitive。
启动命令
示例目录的 package.json 提供了pnpm start(即tsx start.ts)。在packages/llmz/examples目录下执行:
pnpm start 22_chat_streaming启动器 start.ts 的逻辑是:按名字匹配*_chat*/*_worker*目录(22或22_chat_streaming均可命中),然后通过tsx运行该示例的index.ts。它要求以下两个环境变量,缺失时会直接报错退出:
BOTPRESS_BOT_ID:Bot 标识;BOTPRESS_TOKEN:Botpress 访问令牌。
可以在packages/llmz/examples/.env中配置(start.ts 会通过dotenv加载),或直接以环境变量方式注入。示例还用到了chalk(终端着色)与自定义的交互式按钮输入组件 utils/buttons.ts(支持↑/↓选择 Agent 建议的快捷按钮、回车提交、退格编辑)。
核心机制一:onMessageDelta—— 消息体的逐 token 流式渲染
delta 的数据结构
当使用流式 Cognitive 客户端时,消息体(■send块)会以 token 为单位到达。Chat的onMessageDelta在每个分片被解析出的第一时间收到回调,其载荷结构为:
{ id, component, props, delta, content }各字段含义(与 src/chat.ts 中的MessageDelta类型定义一致):
| 字段 | 含义 |
|---|---|
id | 正在流式传输的消息标识,在单次execute()调用内唯一 |
component | 消息的组件名(如message、button) |
props | 消息的 props,在 body 开始流式传输时已是最终值 |
delta | 本次新增的 body 文本分片 |
content | 到目前为止累计的完整 body 文本(含本分片) |
示例中 index.ts 用id作为分组键实现打字机效果:第一次遇到某个id时打印🤖 Agent:前缀,之后每收到一个分片就追加写入delta:
onMessageDelta: (delta) => { if (streaming !== delta.id) { streaming = delta.id process.stdout.write(chalk.bold('🤖 Agent: ')) } process.stdout.write(delta.delta) },delta 是"临时性"的:restart 语义
需要特别注意的是,流式 delta 是**provisional(临时)**的。MessageDelta有两种变体(src/chat.ts):
restart: false:普通文本分片;restart: true:仅重置的分片,在替换文本到达前发出,要求客户端清空该iterationId对应的临时消息。当 provider 重启、响应格式错误或传输失败时会发生,且重启后该迭代的旧预览会被撤回。
而完整消息与代码只有在响应有效且传输成功后才交付——这正是handler被称为"权威交付"(authoritative delivery)的原因。在 runtime/generate.ts 中,预览回调(preview)是逐个await的,且重启型 delta 的撤回失败会被视为终止性错误,以保证替换文本不会在撤回失败后被错误交付。
handler:权威交付与"无 body 组件"的唯一通道
handler在每条完整消息被解析完后调用一次,是所有消息的最终交付点,也是按钮这类无 body 组件唯一会被送达的通道。示例中的处理逻辑(index.ts):
handler: async (component) => { if (isComponent(component, DefaultComponents.Button)) { buttons.push(component.props.label) return } const text = component.children .filter((child) => typeof child === 'string') .join('') .trim() if (!text.length) return transcript.push({ role: 'assistant', content: text }) if (streaming) { // 消息体已被 onMessageDelta 实时打印——只需结束当前行 streaming = null process.stdout.write('\n') } else { // 非流式客户端的兜底:一次性打印整条消息 console.log(`${chalk.bold('🤖 Agent:')} ${text}`) } },这里体现了流式渲染的标准分工:onMessageDelta负责"实时感",handler负责"最终结果"。运行时在 execute.ts 中将两者接线:onSend回调调用ctx.chat.handler(component, messageMetadata),onSendDelta仅在ctx.chat.onMessageDelta存在时传递 delta。
核心机制二:onTrace—— 实时呈现生成代码与工具调用
execute()接受onTrace钩子,每次迭代中的新 trace 会以{ trace, iteration, controller }形式实时推送给调用方(见 execute.ts 对iteration.traces.onPush的订阅)。示例针对三种 trace 类型做了渲染:
1.code_generation_started:模型开始写代码
当模型刚打开一个■run块(代码块)时触发(generate.ts)。此时代码仍在生成中,示例打印⏳ writing code…提示用户等待,同时运行时还会预预热 VM(onRunStart: () => warmupVM(),见 execute.ts)。
2.llm_call_success:代码生成完成
代码生成完成后产生该 trace,其code字段携带 LLM 实际写出的代码。示例在代码运行之前将其渲染在方框中(index.ts):
if (trace.type === 'llm_call_success' && trace.code.trim().length) { console.log(chalk.dim(' ┌─ generated code')) for (const line of trace.code.trim().split('\n')) { console.log(chalk.dim(' │ ') + chalk.magenta(line)) } console.log(chalk.dim(' └─')) }这正是 LLMz 与其他 JSON tool-calling 方案的核心差异:Agent 生成的是可执行代码块,因此可以在一个代码块内编排多个工具调用(详见下一节)。
3.tool_call:工具调用的输入、输出与耗时
每次工具调用完成后产生tool_calltrace。示例读取tool_name、input、output/error、started_at、ended_at并计算耗时(index.ts):
if (trace.type === 'tool_call') { const duration = chalk.dim(`(${(trace.ended_at ?? trace.started_at) - trace.started_at}ms)`) const call = chalk.cyan(`${trace.tool_name}(${trace.input === undefined ? '' : compact(trace.input)})`) if (trace.success) { console.log(` ${chalk.dim('⚙')} ${call} ${chalk.dim('→')} ${chalk.green(compact(trace.output))} ${duration}`) } else { console.log(` ${chalk.dim('⚙')} ${call} ${chalk.dim('→')} ${chalk.red(compact(trace.error))} ${duration}`) } }其中compact()将超长 JSON 截断到 80 字符,保证终端可读性。context.ts中也会消费tool_calltrace(src/context.ts),用于向模型反馈执行历史。
核心机制三:链式工具调用 —— 单个代码块内完成"预订 → 支付"
示例通过"两步预订"强制要求 Agent 进行链式调用(index.ts):
bookTrip(destination, date, travelerName):仅预订,返回{ reservationId, totalUsd },并把预订记录存入内存 Map;processPayment(reservationId):对预订支付,返回{ confirmationId, amountUsd }(RSV-前缀替换为SP-);booked退出要求confirmationId(来自processPayment)。
因此,Agent 必须在一个生成的代码块内先调用bookTrip,再把返回值传给processPayment,最后带上confirmationId退出。README 中的样本代码块展示了完整链条:
const dates = await checkAvailability({ destination: "moon" }) const reservation = await bookTrip({ destination: "moon", date: dates[0], travelerName: "Sylvain Perron" }) const payment = await processPayment({ reservationId: reservation.reservationId }) return { ...payment, date: dates[0] }README 明确指出:这种单块链式编排是 JSON tool-calling 需要多轮往返才能完成的事——JSON 方案每步都要发起一次新的 LLM 调用,而 LLMz 的代码生成模式让 Agent 一次生成、一次执行整条依赖链。指令(instructions)中也对 Agent 做了强约束:"Booking is a two-step process: bookTrip only reserves; the reservation must then be paid with processPayment to get the confirmation id."(index.ts)。
同时,示例还演示了工具的定义方式:每个Tool都带 zui 类型的input/outputschema 与handler(如checkAvailability会校验目的地 id 并抛出可读错误,见 index.ts)。
核心机制四:类型化退出(Typed Exits)与对话循环控制
定义带 schema 的退出
booked与cancelled通过Exit定义,携带 zui(Zod 兼容)schema(index.ts):
const booked = new Exit({ name: 'booked', description: 'The trip was reserved AND paid. Requires the confirmation id returned by processPayment.', schema: z.object({ confirmationId: z.string().describe('The confirmation id returned by processPayment (starts with SP-)'), destination: z.string(), date: z.string(), travelerName: z.string(), amountUsd: z.number(), }), }) const cancelled = new Exit({ name: 'cancelled', description: 'The user decided not to book a trip', schema: z.object({ reason: z.string() }), })Exit的 schema 会被转为 JSON Schema 存储,并在执行结束时对退出载荷做运行时校验(见 src/exit.ts 的构造与校验逻辑)。result.is(exit)会把result.output收窄为对应 schema 推导出的类型。
用result.is(exit)处理三种结束分支
示例的对话循环(index.ts)每次调用execute()后按顺序检查:
if (result.is(booked)) { // result.output 由 exit schema 完全类型化 console.log(chalk.bold.green('🎫 Trip booked!')) console.log(chalk.green(` Confirmation: ${result.output.confirmationId}`)) console.log(chalk.green(` Destination: ${result.output.destination}`)) console.log(chalk.green(` Launch date: ${result.output.date}`)) console.log(chalk.green(` Traveler: ${result.output.travelerName}`)) console.log(chalk.green(` Amount: $${result.output.amountUsd.toLocaleString('en-US')}`)) break } if (result.is(cancelled)) { console.log(chalk.bold.yellow('🚪 Conversation ended — no booking')) console.log(chalk.yellow(` Reason: ${result.output.reason}`)) break } if (!result.is(ListenExit)) { console.log(chalk.red(`✖ Execution ended unexpectedly: ${result.status}`)) break }内建的ListenExit是"把回合交还用户"的信号:只要执行以ListenExit结束,循环就调用交互式prompt()收集用户输入并追加到 transcript,进入下一轮;否则(booked/cancelled/异常)终止循环。result.is(exit)的类型守卫实现在 src/result.ts,其本质是检查result.exit === exit且状态为success。
核心机制五:每轮统计页脚(Stats Footer)
每轮结束,示例打印一行统计信息(index.ts):
const printStats = (result: ExecutionResult) => { const iteration = result.iteration const llm = iteration?.llm const tokens = iteration?.tokens const generationMs = llm ? llm.ended_at - llm.started_at : undefined const parts = [ `model ${llm?.model ?? 'n/a'}`, `turns ${turns}`, `iterations ${result.iterations.length}`, `code generation ${generationMs !== undefined ? `${generationMs}ms` : 'n/a'}`, `tokens in ${tokens?.input.toLocaleString('en-US') ?? 'n/a'}`, `tokens out ${tokens?.output.toLocaleString('en-US') ?? 'n/a'}`, `ttft ${llm?.time_to_first_token !== undefined ? `${llm.time_to_first_token}ms` : 'n/a'}`, ] console.log(chalk.dim(` ─ ${parts.join(' · ')}`)) }数据来源:
result.iteration:最后一次迭代(result.ts 的get iteration(),取context.iterations.at(-1));result.iteration.llm:本次迭代的 LLM 调用信息,含model、started_at、ended_at(ended_at - started_at即代码生成耗时)与time_to_first_token(首 token 延迟,字段定义见 src/context.ts);result.iteration.tokens:本次迭代的input/outputtoken 计数;result.iterations.length:累计迭代次数;turns由示例本地计数。
ExecutionResult.tokens还提供了跨迭代聚合的{ input, output, total }(result.ts)。README 中的样例行即该函数的真实输出:
─ model openai:gpt-5.2 · turns 2 · iterations 2 · code generation 1764ms · tokens in 3,360 · tokens out 109 · ttft 663ms完整样本轮次
以下是 README 提供的示例运行轮次,直观展示了"流式正文 + 代码框 + 工具调用 + 统计页脚 + 退出横幅"的全部要素:
👤 User: Book the Moon on the first available date. My name is Sylvain Perron. 🤖 Agent: Got it — Moon trip for Sylvain Perron. Let me line up the earliest launch… ┌─ generated code │ const dates = await checkAvailability({ destination: "moon" }) │ const reservation = await bookTrip({ destination: "moon", date: dates[0], travelerName: "Sylvain Perron" }) │ const payment = await processPayment({ reservationId: reservation.reservationId }) │ return { ...payment, date: dates[0] } └─ ⚙ checkAvailability({"destination":"moon"}) → ["2026-09-14",…] (0ms) ⚙ bookTrip({"destination":"moon",…}) → {"reservationId":"RSV-MOO-3645","totalUsd":125000} (0ms) ⚙ processPayment({"reservationId":"RSV-MOO-3645"}) → {"confirmationId":"SP-MOO-3645",…} (0ms) 🤖 Agent: All set — you're booked for the Moon! Confirmation SP-MOO-3645. ─ model openai:gpt-5.2 · turns 2 · iterations 2 · code generation 1764ms · tokens in 3,360 · tokens out 109 · ttft 663ms 🎫 Trip booked!注意轮次中的几处细节:checkAvailability的输出被compact()截断为["2026-09-14",…];bookTrip的输入同样被截断;三个工具调用依次完成后,Agent 才输出最终确认消息——因为代码块执行完毕前,后续消息的预览会被抑制(见 generate.ts 的runCompleted逻辑)。
传输无关性:从终端到 WebSocket / SSE 前端
README 强调:"The same pattern works for any transport"——流式渲染模式与具体传输层无关。本示例把"客户端"实现为终端,用process.stdout.write/console.log输出;迁移到前端时,只需:
- 把
onMessageDelta中的process.stdout.write(delta.delta)换成WebSocket 发送或SSE write; - 把
onTrace中渲染代码框与工具调用的console.log换成对应的前端消息协议; - 把
result.is(exit)的终端横幅换成前端 UI 状态(如订单确认卡片)。
消息体的分片(delta)、组件类型(component)、props 与累计内容(content)都在 delta 载荷中显式给出,前端无需自行拼装即可实现打字机效果与组件化渲染。handler作为权威交付通道,负责最终一致性——即使某条流被撤回(restart),完整消息仍会以正确形态送达。
关键源码索引
| 关注点 | 位置 |
|---|---|
| 完整示例实现 | packages/llmz/examples/22_chat_streaming/index.ts |
| 示例说明文档 | packages/llmz/examples/22_chat_streaming/README.md |
Chat基类、MessageDelta类型 | packages/llmz/src/chat.ts |
Exit定义与 schema 校验 | packages/llmz/src/exit.ts |
ExecutionResult与is(exit)类型守卫 | packages/llmz/src/result.ts |
| 执行循环与钩子接线 | packages/llmz/src/runtime/execute.ts |
| 流式解析与 trace 生成 | packages/llmz/src/runtime/generate.ts |
| 示例启动器与环境变量检查 | packages/llmz/examples/start.ts |
| 交互式按钮输入组件 | packages/llmz/examples/utils/buttons.ts |
小结
22_chat_streaming是一个"一屏看尽 LLMz 执行全貌"的教学示例:onMessageDelta负责消息体的打字机式实时渲染,handler保证权威交付与无 body 组件(按钮)的唯一通道,onTrace把生成代码、工具调用输入输出与耗时全部透明化,类型化Exit配合result.is()提供类型安全的多分支收尾,统计页脚则从result.iteration.llm与result.iteration.tokens读出模型、耗时与 token 消耗。这套模式与传输层解耦,可以原样迁移到 WebSocket / SSE 前端,是构建"所见即所得"的流式 LLM Agent 界面的完整参考实现。
- AI 应用
- 后端
【免费下载链接】botpress
The open-source hub to build & deploy GPT/LLM Agents ⚡️
相关推荐
JSONVue开发者指南:从源码编译到打包发布全流程
JSONVue开发者指南:从源码编译到打包发布全流程 JSONVue是一款专为基于Chromium的浏览器设计的JSON格式化与查看工具,本指南将带你完成从源码
用 Yeti Affix 组件拼接表单控件:共享边框输入组(单位、符号、按钮)的实现与无障碍实践
用 Yeti Affix 组件拼接表单控件:共享边框输入组(单位、符号、按钮)的实现与无障碍实践 Affix 是 Yeti 中的一个 CSS first 组件,
AI 应用后端WinSW扩展开发指南:如何编写自定义扩展DLL
WinSW扩展开发指南:如何编写自定义扩展DLL WinSW 是一款免费开源的工具,可以把任意可执行文件包装成 Windows 服务来运行。当 WinSW 自带
AI 应用后端
创作声明:本文部分内容由AI辅助生成(AIGC),仅供参考