尧图精选

大模型流式输出解析:从JSON.parse到Zod校验与Tool Calls全指南

🕒 发布时间:2026/9/18 9:21:46 📁 来源:尧图网络
很多人第一次接触大模型的流式输出都会遇到同一个诡异场景界面上的字明明一个字一个字蹦得很流畅结果前端却报了个Unexpected end of JSON input然后整个对话就卡死了。再往后翻后端日志里是一整段被腰斩的 JSON引号没闭合、括号缺一半看起来就像代码被狗啃了。这个问题不是 bug而是你还没理解流式输出的底层逻辑——它压根就不是给你一次性JSON.parse用的。这篇文章我会从流式输出聊到高阶结构化解析覆盖 JSON、XML、Zod 校验和 Tool Calls工具调用四条主线。内容不绕弯子全部来自我实际做项目踩过的坑和最终沉淀下来的轮子。无论你是在写聊天机器人、AI Agent、内容生成工具还是做复杂工作流编排这篇文章都能帮你把模型吐出来的字变成程序能直接消费的数据。1. 流式输出的本质为什么你的 JSON.parse 总是失败1.1 流式协议下完整是一种错觉大模型的流式输出走的基本是 Server-Sent EventsSSE协议服务端把 token 一个接一个地推给客户端。在 OpenAI、Anthropic 以及国内各大模型厂商的兼容接口里返回的每个 chunk 通常长这样{choices:[{delta:{content:你好},index:0}]} {choices:[{delta:{content:今天},index:0}]} {choices:[{delta:{content:天气不错},index:0}]}每个 delta 里的content字段只是当前这个 token 的文本不携带任何上下文。前端拿到什么就是什么没有任何机制保证第 N 个 chunk 的结尾一定是合法的 JSON 语法位置。这意味着什么假设你让模型输出一段 JSON 格式的自我介绍理想中的完整结果是这样{name:张三,age:28,skills:[JavaScript,Python]}但在流式输出过程中你会陆续收到这些碎片{na me: 张 三,a ge:2 8,...如果把每个 chunk 单独拿来JSON.parse100% 会失败如果把所有 chunk 攒到最后再 parse那前面等待的时间里你的 UI 只能干瞪眼或者显示加载动画这又背离了流式的初衷。1.2 结构不完整只是第一层坑你以为攒够整个响应再解析就万事大吉了年轻人还是太天真。结构不完整只是最表面的那一层真正折磨人的是下面这几类情况转义符被拦腰截断比如模型输出{content:他说\你好\}流式传输时\这个反斜杠和引号可能被分到两个 chunk 里前端如果按 chunk 做简易处理就会出现多一个引号、少一个反斜杠的诡异情况。Unicode 字符的拆分某些中文字符或 emoji 在传输过程中以\ud83d\ude00这样的 Unicode 代理对形式出现一个代理对拆在两个 chunk 里解析时就会得到一串乱码。嵌套结构的暂时性非法即便你有耐心攒到最后一个 chunk如果模型在生成中途发生了截断上下文长度超限、输出 max_tokens 设太短、网络中断你凑齐的依然是一个残缺的 JSON而且这种残缺没有任何规律可循。我见过不少团队前端为了做打字机效果直接在流式回调里把当前字符串塞进JSON.parse一抛异常就 white screen。说实话这个方案连及格线都不到。1.3 增量可用才是流式解析的正确理解方式流式结构化解析的核心思路不是追求任意时刻都能拿到完整对象而是做到任意时刻都能拿到当前已输出部分的最优可用状态。举个例子。模型正在生成这样一个对象{title:AI 入门指南,tags:[编程,人工智能],content:长文本...}当模型刚刚输出完{title:AI 入门指南,tags:[编程时你的前端其实已经可以渲染标题了。tags数组虽然还不完整但前面已经闭合的部分是确定的、可靠的。我们需要的是这样一种能力Parser 始终从流的开头开始尝试解析当前所有已接收的文本能解析多少就解析多少解析不了的部分就留着等下一个 chunk。这就像你小时候玩拼图先把确定的边角拼好中间缺失的部分等拼图块到了再填。前端的 UI 可以做到标题一出就渲染标题标签逐步出现逐步补充用户体验完全上一个台阶。2. 流式 JSON 解析的三条路线与我的选型建议2.1 路线一攒批解析简单但延迟代购最原始的做法把所有的 delta 内容拼到一个字符串里等stream.end事件触发后再统一JSON.parse。let raw ; for await (const chunk of stream) { raw chunk.choices[0]?.delta?.content || ; } const result JSON.parse(raw);优点就一个字稳。没有中途解析的各种边界问题。但代价是响应延迟完全没被利用起来首屏内容的等待时间和非流式接口没有任何区别。用户的打字机效果没了只剩一个转圈圈。适用场景后端内部调用、离线批量处理、对首字延迟不敏感的非交互任务。如果你做的是喂一篇文章进去返回结构化摘要这种异步任务这条路完全够用。2.2 路线二逐字符增量解析极致体验但要小心漏血如果要追求极致的流式体验就得做增量解析。这里不用自己手写状态机社区里已经有比较成熟的方案比如 Node.js 生态的stream-json或者 Python 那边的partial-json-parser。它们的行为一句话来解释维护一个内部状态记录当前解析到哪个位置遇到不完整的结构就停下来等新数据来了接着解析。我拿stream-json的调用方式举个例子简化版const { parser } require(stream-json); const { pick } require(stream-json/filters/Pick); const jsonParser parser(); jsonParser.on(data, (data) { if (data.name keyValue data.key title) { console.log(标题已就绪, data.value); } }); // 把流式的文本 chunk 不断喂进去 jsonParser.write({title:AI); jsonParser.write( 入门指南,tags:[编); jsonParser.write(程]}); jsonParser.end();这种方案能在title:AI 入门指南刚完成时就把标题字段抛给你UI 实时性拉满。但我要给你提个醒增量解析不是银弹。当 JSON 里有深层嵌套数组或对象时stream-json这类库的内部状态非常复杂一旦遇到模型输出不按套路出牌比如中途突然转了话题、插了一个非 JSON 的注释整个流就崩了而且很难 recover。使用前要做好充分测试特别是针对你业务里的真实 prompt 做边界用例测试。2.3 路线三语义分段缓冲工程实践中最推荐我自己的混饭吃方案是第三种——不等完整 JSON也不做细粒度的标记级解析而是按照业务字段的语义做分段缓冲。核心思路先通过 prompt 让模型输出结构化时遵循固定的字段顺序然后前端解析时每次拿到新 chunk 都尝试做一次低价的正则匹配把已经闭合的字段提前取出来渲染。举一个实际例子我让模型生成一篇带标题、标签和正文的文章prompt 中约定输出格式{ title: 标题, tags: [标签1, 标签2], content: 正文可能很长 }前端处理流式文本时做三件事提取 title用/title\s*:\s*([^]*)/正则一旦匹配上就立刻渲染标题。提取 tags等到tags出现后尝试解析[...]中已经闭合的部分做法是找最后一个合法的]位置。提取 content一旦content字段出现后面的所有文本先原样显示不急着做 JSON 解析等流结束后再补一次完整解析来修正。function extractField(raw, fieldName) { try { const match raw.match(new RegExp(${fieldName}\\s*:\\s*([\\s\\S]*?)(?,|$))); if (!match) return null; return JSON.parse(match[1]); } catch { return null; // 还没闭合等下一个 chunk } }这个正则的原理是从fieldName:后面开始尽可能多地匹配直到遇到下一个逗号或字符串结束。每次新 chunk 到达时调用一次能解析出来就更新 UI解析不出来就返回null保持现状。这套方案的优点是逻辑直观、没有复杂状态、对异常容忍度高因为最坏情况下还可以等流结束后做一次兜底解析。缺点是字段提取顺序依赖 prompt 约定模型不听话时可能拿到null或者拿到的是不完整的数组。不过实测下来只要 prompt 写得清楚、模型规格别太差这套方案在 95% 以上的场景都能跑得很顺。3. XML 结构化输出的场景它还没死而且有独门优势3.1 为什么还有人用 XML 而不是 JSON在进入 Zod 和 Tool Calls 之前先把 XML 这条线聊透。可能有人觉得都什么年代了还在用 XML但在大模型领域XML 作为输出格式一直有自己的江湖地位。原因有三XML 天然支持文本与标签混合。比如你要让模型生成一篇文章里面某些段落要加粗、某些段落是列表用 XML 很自然articlep这是正文包含strong重点/strong内容/p/article。如果换成 JSON你得设计一堆子结构来表示这一段文本里哪些 token 被加粗非常痛苦。XML 的容错性优于 JSON。JSON 少一个括号全盘崩溃XML 少一个闭合标签最多警告解析器通常能自动修正。对于输出稳定性不太可控的大模型来说XML 的容错性在早期是很宝贵的。思维链Chain of Thought场景。很多推理模型要求在最终答案前输出思考过程用thinking.../thinking这样的 XML 标签包起来模型自己就知道这段不该给用户看下游程序可以精准地把它剥掉。3.2 流式 XML 的解析策略标签栈维护法XML 流式解析不像 JSON 那么痛苦因为 XML 的语法天然是标签驱动的。你不需要完整闭合就能知道当前处于哪个层级。我的做法是维护一个标签栈遇到 tag → 入栈知道当前在哪个节点 遇到 /tag → 出栈节点闭合 遇到文本 → 只有一个栈顶标签文本自然归属到那个节点const stack []; const onChunk (chunk) { const regex /\/?([a-zA-Z0-9_-])[^]*|([^])/g; let match; while ((match regex.exec(chunk)) ! null) { if (match[1]) { if (chunk.startsWith(/, match.index)) { const closed stack.pop(); onNodeClosed(closed); // 当前标签内容已完整 } else { stack.push(match[1]); } } else if (match[2]) { const currentTag stack[stack.length - 1]; onText(currentTag, match[2]); // 文本追加到当前标签 } } };这个方案配合上面的语义分段思路能实现很流畅的边出字边出结构体验。比如生成一篇博客title一闭合标题就能立刻渲染到页面上paragraph的文本累积到一定量就能边出字边展示。用户体感上这跟普通聊天流式输出没有区别但你拿到的是结构清晰的内容。3.3 XML vs JSON 的实战选型建议我自己的经验是纯数据交互用 JSON长文本内容生成用 XML工具调用看各家生态。具体点说维度JSONXML结构化程度高强类型表达能力强中标签语义更贴近文本流式解析复杂度高嵌套结构易碎低标签天然有边界适合场景API 参数、Tool Calls、配置数据长文生成、富文本、思维链模型的输出稳定性依赖 prompt 工程相对更稳容错性好下游消费成本低生态工具多中解析库不如 JSON 顺手如果你做一个同时需要结构化数据和长文内容的应用我的建议是混合模式外层用 JSON 包结构内层的长文本字段用带 XML 标签的内容填充。这样既能拿到实体字段又保留了文本的富语义。下一节的 Zod 就专门处理这种复杂嵌套场景。4. Zod 在 LLM 输出链路中的真正位置运行时校验不是可选项4.1 为什么模型输出必须过校验器很多人写大模型应用会默认模型返回什么就是什么。这是一个相当危险的假设。大模型的输出本质是概率生成的文本它只是看起来很像 JSON并不保证是合法 JSON更不保证 JSON 的字段符合你的预期。我遇到过这些真实事故模型输出{age: 二十八}但业务侧需要age是 number模型漏掉了必填字段name前端渲染时直接undefined模型在 JSON 里多加了一个字段extra_data后端入库时报错模型输出了一个空数组[]却拒绝给出任何解释。这些问题单靠 JSON.parse 完全发现不了。你必须引入一个运行时校验层而这恰好是 Zod 的看家本领。4.2 Zod 核心用法schema 即契约Zod 是一个 TypeScript 生态的运行时校验库核心语法简洁得不像话。你定义一个 schema然后拿它去校验数据import { z } from zod; const ArticleSchema z.object({ title: z.string().min(1).describe(文章标题), tags: z.array(z.string()).max(10).describe(标签列表), content: z.string().describe(文章正文支持XML标签), word_count: z.number().int().positive().describe(正文字数), }); type Article z.infertypeof ArticleSchema;校验动作用safeParse它能返回成功或失败的完整信息不会像parse那样直接抛异常炸掉进程const rawData JSON.parse(rawJson); // 假设已经拿到 JSON 对象 const result ArticleSchema.safeParse(rawData); if (result.success) { // result.data 是经过校验和类型转换的、可安全使用的对象 renderArticle(result.data); } else { // result.error 里详细列出了哪个字段为什么失败 handleValidationError(result.error.issues); }这个safeParse还有一个容易被忽略的好处Zod 会在校验的同时做类型收窄和转换。比如你 schema 里定义age: z.number()前端传进来一个字符串28默认会校验失败如果你用z.coerce.number()Zod 会尝试把它转成 number。这对模型输出类型不是特别稳定的场景极其有用。4.3 流式 Zod 的配合模式先分段再整体校验流式场景下Zod 的单次校验没法直接做增量因为你要校验的是一个完整的对象。那怎么办我的实践策略是这样的流式阶段用第二节的语义分段缓冲方案提前渲染 UI追求打字机效果流结束后把累积的完整 JSON 一次性交给 Zod 做整体校验如果校验失败根据失败原因决定是重试、修复还是优雅降级。这里最关键的是第 3 步的失败策略。我见过很多团队一遇到 Zod 校验失败就盲目让模型重写结果 token 费用翻了三倍问题还没解决。正确的做法是把 Zod 的校验错误信息回传给模型让它基于错误做修正。const result ArticleSchema.safeParse(rawData); if (!result.success) { const errorMessages result.error.issues.map(issue ({ path: issue.path.join(.), message: issue.message, expected: issue.expected, })); // 把 errorMessages 丢回模型要求它只输出修正后的 JSON const correctedJson await retryModel(errorMessages, rawJson); const retryResult ArticleSchema.safeParse(JSON.parse(correctedJson)); // 只允许重试一次防止无限循环消耗预算 }实测下来把 Zod 的path和message直接当成新的提示词片段丢给模型修正成功率能到 90% 以上。因为模型看到的不再是冷冰冰的JSON 格式错误而是具体到第 3 个字段age期望 number 类型实际得到 string的精确反馈它就知道怎么改了。4.4 设计 LLM 输出 Schema 的几条经验用 Zod 定义 schema 时有几个细节是纯踩坑踩出来的经验第一尽量给每个字段加.describe()。这个描述会被序列化到 JSON Schema 里如果你在做 Tool Calls 或 Function Calling模型的底层 prompt 会自动把它当字段说明用输出的命中率会明显提升。第二不要把所有字段都设为必填。模型输出时偶尔会漏字段这是常态。建议把非关键的字段用.optional()或.default()处理。比如tags字段如果你想要它但允许缺失就写成z.array(z.string()).default([])。第三用.strict()做字段白名单控制。如果某个接口只接受固定字段加.strict()之后模型输出的多余字段会被标记为错误。这在对接外部系统时非常重要避免多字段入库失败这种低级 bug。但如果你只是内部展示用就别开strict()让模型自由发挥反而体验更好。5. Tool Calls从解析模型的话到操作系统的手5.1 工具调用的协议机制聊完纯文本的结构化输出终于要聊到 Tool Calls 了。如果说 JSON/Zod 解决的是怎么读模型输出的内容那 Tool Calls 解决的是怎么让模型驱动你的代码去干活。用大白话解释Tool Calls 不是让模型直接调用工具而是让模型在一段结构化的 JSON 中声明自己想要调用哪个工具、传什么参数由你的程序去实际执行。在 OpenAI 兼容接口中非流式响应里你会看到{ choices: [{ message: { role: assistant, content: null, tool_calls: [{ id: call_abc123, type: function, function: { name: query_weather, arguments: {\city\:\上海\} } }] } }] }注意arguments字段是一个 JSON 字符串不是对象。这是设计者故意为之——因为流式输出时这个字段会被拆成无数碎片。你可以把它理解为模型给你塞的一张命令纸条纸条本身是明文文本但内容是结构化的参数。而在流式响应里tool_calls 是以增量形式出现的{choices:[{delta:{tool_calls:[{index:0,id:call_abc123,function:{name:query_weather,arguments:}}]}}]} {choices:[{delta:{tool_calls:[{index:0,function:{arguments:{\ci}}]}}]} {choices:[{delta:{tool_calls:[{index:0,function:{arguments:ty\:\上}}]}}]} {choices:[{delta:{tool_calls:[{index:0,function:{arguments:海\}}}]}}]}arguments被拆成了四个片段。你要做的就是把这些片段按 index 分组拼接起来最终得到完整的参数 JSON。5.2 流式 Tool Calls 的增量拼接与合法性校验我在实际项目中写过一个工具调用收集器核心逻辑分四步。先看完整代码再逐段解释interface ToolCallAccumulator { id: string; name: string; arguments: string; } function collectStreamingToolCalls(chunks: DeltaChunk[]): ToolCallAccumulator[] { const accumulators: ToolCallAccumulator[] []; for (const chunk of chunks) { const toolCalls chunk.choices?.[0]?.delta?.tool_calls; if (!toolCalls) continue; for (const call of toolCalls) { const index call.index ?? 0; if (!accumulators[index]) { accumulators[index] { id: call.id ?? , name: call.function?.name ?? , arguments: , }; } if (call.id) accumulators[index].id call.id; if (call.function?.name) accumulators[index].name call.function.name; if (call.function?.arguments) { accumulators[index].arguments call.function.arguments; } } } return accumulators; }这段代码的关键点用 index 分组并行工具调用时模型会发起多个 tool call每个都有独立的index。不按 index 分组所有参数就会串成一锅粥。只追加不覆盖流式场景下id和name只在第一个 chunk 里出现一次而arguments是不断追加的所以用而不是。参数合法性校验收集完arguments后我会做一次JSON.parse加一次 Zod 校验通过与 4.3 节相同的错误回传机制修复确保真正执行工具时参数是安全的。很多人在这一步只做JSON.parse不做 Zod 校验等到调真实 API 时才发现query_weather的参数city是 string却混进了一个数字。这种 bug 用运行时校验会立刻暴露否则你排查半天都不一定能定位到问题。5.3 工具结果回流的完整循环Tool Calls 拿到参数之后并不是终点关键在执行完之后怎么把结果喂回模型让模型基于结果生成最终回答。整个链路是用户提问 → 模型返回 tool_calls → 程序执行工具 → 将工具结果作为消息发给模型 → 模型基于结果生成最终回答代码层面大概是// 1. 用户消息 const messages [ { role: user, content: 上海今天天气怎么样 }, ]; // 2. 第一轮模型返回 tool_calls const firstResp await chatCompletion(messages, [weatherTool]); // 3. 把 assistant 的 tool_calls 原样推入历史 messages.push(firstResp.choices[0].message); // 4. 遍历每个 tool_call执行真实工具 for (const toolCall of firstResp.choices[0].message.tool_calls) { const args JSON.parse(toolCall.function.arguments); const result await executeTool(toolCall.function.name, args); // 5. 把工具结果以 roletool 的消息推入历史 messages.push({ role: tool, tool_call_id: toolCall.id, content: JSON.stringify(result), }); } // 6. 最后一轮模型基于工具结果生成最终回复 const finalResp await chatCompletion(messages, [weatherTool]); console.log(finalResp.choices[0].message.content);这里有一个非常反直觉的细节assistant 的 tool_calls 必须作为一个消息推回历史而不是把工具执行逻辑直接写死。如果你漏掉了这一步模型会完全不知道刚才发生了什么最终回答会牛头不对马嘴。另外在流式场景下步骤 2 的firstResp搜集过程和步骤 6 的最终回复生成可以一边增量接收tool_calls.arguments一边在界面上展示操作进度。但我的建议是工具调用这一步不要做全流式因为工具执行本身的耗时通常远大于网络传输你完全可以在 tool_calls 收集完整、参数校验通过后再一次性执行工具、拿到结果、把最终回答以流式方式推给用户。5.4 并行工具调用的边界问题很多模型现在支持一次返回多个 tool_calls也就是所谓并行工具调用。比如用户问北京和上海今天天气怎么样模型可能一口气返回两个 callcall_001query_weather({city:北京})call_002query_weather({city:上海})这本身很好但你要注意两个问题第一个是执行顺序。两个工具之间如果没有依赖关系可以用Promise.all并发执行但如果有依赖比如先查订单号再查物流就必须显式地在 prompt 或后处理逻辑里串行执行。模型是不会替你保证依赖顺序的。第二个是入参类型。并行调用时模型更容易在参数上犯糊涂比如两个 call 都传了同一份参数。Zod 校验在这里不是可选项它能在执行前挡住约 80% 的错误参数。6. 完整实战骨架流式 结构化 Tool Calls 的最小实现到这里把前面四条主线串起来做一个真正可运行的 Node.js 实战骨架。这个骨架会打通流式接收 → 增量 UI 更新 → 工具调用执行 → Zod 校验 → 最终回复的完整闭环。我用的是openainpm 包其他兼容接口通义、DeepSeek、moonshot 等逻辑相同只需改 baseURL 和 apiKey。6.1 环境准备与依赖安装npm install openai zod # 如果你用 TypeScript再加 npm install -D typescript types/node tsximport OpenAI from openai; import { z } from zod; const client new OpenAI({ apiKey: process.env.OPENAI_API_KEY, baseURL: process.env.OPENAI_BASE_URL, // 对接其他兼容服务商时配置 });6.2 定义工具与参数 Schema这一步很关键工具的参数 schema 既用于模型理解参数含义也用于执行前的运行时校验。我习惯用 Zod 定义一份再用zod-to-json-schema转成 OpenAI 需要的 JSON Schema 格式import { zodToJsonSchema } from zod-to-json-schema; const WeatherArgsSchema z.object({ city: z.string().describe(城市名例如上海、北京), date: z.string().optional().describe(日期格式 YYYY-MM-DD默认今天), }); const weatherTool { type: function as const, function: { name: query_weather, description: 查询某个城市某一天的天气情况, parameters: zodToJsonSchema(WeatherArgsSchema, WeatherArgsSchema), }, };注意description字段千万不要偷懒省略。这个字段会直接作为模型决定要不要调用这个工具、该怎么填参数的上下文依据写得好工具调用的命中率肉眼可见地提升。6.3 流式接收与解析完整流程核心会话循环如下我加了不少注释帮你理解每一段的职责async function runAgent(userMessage: string) { const messages: OpenAI.Chat.Completions.ChatCompletionMessageParam[] [ { role: user, content: userMessage }, ]; let finalResponse ; // 最多执行 3 轮工具调用防止模型和工具之间无限循环 for (let round 0; round 3; round) { const stream await client.chat.completions.create({ model: gpt-4o-mini, messages, tools: [weatherTool], stream: true, }); const toolCallAccums: { index: number; id?: string; name?: string; args: string; }[] []; let contentBuffer ; // 第一遍接收流式数据 for await (const chunk of stream) { const delta chunk.choices[0]?.delta; if (delta?.content) { contentBuffer delta.content; // 这里可以做打字机效果。如果是结构化 JSON 字段 // 就套用第 2 节的语义分段提取实时渲染已闭合字段 process.stdout.write(delta.content); } if (delta?.tool_calls) { for (const call of delta.tool_calls) { const idx call.index ?? 0; toolCallAccums[idx] ?? { index: idx, args: }; if (call.id) toolCallAccums[idx].id call.id; if (call.function?.name) toolCallAccums[idx].name call.function.name; if (call.function?.arguments) { toolCallAccums[idx].args call.function.arguments; } } } } // 模型没有发起工具调用说明已经给出最终答复结束循环 if (toolCallAccums.length 0) { finalResponse contentBuffer; break; } // 把含 tool_calls 的 assistant 消息写回消息历史 messages.push({ role: assistant, content: contentBuffer || null, tool_calls: toolCallAccums.map((acc) ({ id: acc.id!, type: function as const, function: { name: acc.name!, arguments: acc.args, }, })), }); // 逐个执行工具 for (const acc of toolCallAccums) { const argsResult WeatherArgsSchema.safeParse(JSON.parse(acc.args)); let result: unknown; if (argsResult.success) { result await executeWeatherTool(argsResult.data); } else { result { error: 参数校验失败, details: argsResult.error.issues, }; } messages.push({ role: tool, tool_call_id: acc.id!, content: JSON.stringify(result), }); } } console.log(\n最终回复, finalResponse); } async function executeWeatherTool(args: { city: string; date?: string }) { // 这里对接真实天气 API或查数据库或调用内部服务 return { city: args.city, date: args.date ?? today, weather: 晴, temperature: 25, wind: 东南风 3 级, }; }这个骨架的核心价值在于它把流式接收、增量收集 tool_calls、参数校验、工具执行、消息回填这一套完整逻辑放在了一个循环里。你在这个基础上扩展任何业务功能都不用再从头纠结协议细节。6.4 前端 UI 如何配合这个链路如果你的前端也要做流式打字机效果可以这样配合后端后端通过 SSE 持续向前端推送事件事件类型分两种text_delta内容增量和tool_call_status工具调用状态。前端收到text_delta就追加到当前消息气泡里收到tool_call_status就显示一个正在查询天气...的 loading 状态工具执行完毕、模型继续输出最终回答时再以text_delta方式推送到同一个气泡。我见过很多团队把工具调用过程也做成流式打字机结果用户看到一堆{city:上海}的裸 JSON 在界面上乱跳。工具调用过程应该做成状态提示而不是内容流。这属于产品细节但直接影响用户体感值得你多花 5 分钟思考一下。7. 我踩过的坑和最终沉淀的调优心得7.1 陷阱一SSE 断线之后你之前攒的缓冲区全废了流式连接不是永远可靠的中间网络抖动、服务端超时都会导致连接中断。大多数人的第一版实现是断线就重连从头再跑一次这在纯文本聊天场景还能忍但如果你是在做长文档结构化输出重跑一次的 token 费用足够让你心疼半天。我的方案是定期持久化已解析的结构快照。具体做法是每次新的title/tags等字段被成功提取后就把当前快照已确认的 JSON 片段存在内存或 Redis 里并记录一个游标位置。断线重连时把已确认的字段直接拿出来prompt 里加一句以下结构已确认请基于此继续生成让模型从断点接着输出。实测这个方案能让断线恢复的 token 浪费从 100% 降到 15% 以内。7.2 陷阱二Zod 校验失败后的无脑重试会让你破产前面提过一句重试必须有次数上限。这里再展开说说为什么。模型调用不是零成本的一次较长上下文的接口调用可能吃掉几千甚至上万 token。如果你的业务逻辑是校验失败 → 整段重试一个字段的错误轻则让 token 消耗翻倍重则陷入死循环直到你预算烧完才停下来。我的硬性规则是第一次校验失败把错误细节回传给模型面向错误修正再试一次第二次还失败不再让模型重写而是直接返回一个当前功能暂不可用的降级提示把原始输出以纯文本形式展示给用户所有重试日志全量落盘方便事后定位是哪类字段经常出错针对性优化 prompt 和 schema。这样才能把模型不确定性带来的损失锁在可控范围。7.3 陷阱三工具调用的死循环判定标准Agent 类应用最常见的运维事故之一是工具循环——模型连续调用同一个工具、拿到同样的结果、再次调用同一个工具像一只追自己尾巴的猫。我的判定标准有两条同一个(tool_name, arguments_hash)组合在 3 轮内重复出现 2 次以上直接终止循环工具返回error或空结果时主动在下一轮 messages 中插入一条系统消息你刚才使用的工具未能返回有效结果请不要再次调用同一工具尝试换一种方式回答用户。这个方法不高级但真的能救命。7.4 解析性能调优别拿到一个 token 就解析一次流式处理最忌讳的就是一来数据就全量解析。假如一个字段有 2000 个 token你每个 token 都去跑一遍正则、做一次JSON.parseCPU 和内存开销会非常难看尤其在 Node.js 单线程环境里这还会阻塞事件循环导致 UI 掉帧。我的调优策略是节流解析维护一个自上次解析以来的累计字符数超过 20~30 个字符再执行一次解析同时设置一个 50ms 的最短解析间隔。这样既能保证解析频率跟得上用户视觉需求又不会把 CPU 打满。以下是简化代码let lastParseTime 0; let pendingBuffer ; function onDelta(chunkText: string) { pendingBuffer chunkText; const now Date.now(); if (pendingBuffer.length 30 now - lastParseTime 50) { tryExtractFields(pendingBuffer); lastParseTime now; pendingBuffer ; // 只清空已尝试解析的部分 } }这个30 字符 50ms的组合是我试下来的一个甜点值既不会让 UI 更新过慢也不会让解析器频繁空转。你可以根据自己的场景微调。7.5 从技术可用到产品好用最后一点想法写到这里整套流式 → 结构化 → 校验 → 工具调用的链路已经完整走了一遍。回顾下来最核心的认知变化是大模型应用开发的难点从来不在调用一个接口而在如何把一个概率性的文本生成器变成一个稳定可控的系统组件。流式解析是让你不牺牲体验Zod 是让你不信任不可信的输出Tool Calls 是让文本真正落成动作。这三件事一环扣一环缺了任何一环你最终做出来的东西都会在某个边界条件下崩给你看。我自己的实践体会是不要急于上最复杂的流式解析方案先把攒批解析 Zod 校验 失败重试这一套地基打好再根据产品需求逐步升级到语义分段解析和流式 Tool Calls。每往上升一级都要配套对应的测试用例和监控指标否则你很难判断性能瓶颈到底出在模型、网络还是自己的代码上。这个领域每天都在变模型的能力、SDK 的接口、各家厂商的兼容性都在快速迭代但底层的结构化链路逻辑是稳定的。把这套链路吃透往后不管换什么模型、接什么平台你都能在最短时间内搭出可用的方案。
上一篇/下一篇内容由系统自动关联 返回资讯列表 →