1. 流式输出的错觉:你以为在拼字符串,实际在拼"半成品"
做 AI 应用的同学应该都有过这种体验:没开流式输出之前,一切岁月静好,JSON.parse随便用。一旦把接口切到stream: true,世界立刻支离破碎。模型最后吐出来明明是一个合法 JSON,可你在每个 chunk 到达的瞬间去解析,得到的永远是Unexpected end of JSON input。这篇文章专门聊这个大坑:从流式输出的底层原理,到 JSON、XML 的容错解析,再到 Zod 校验和 Tool Calls 的增量拼装,最后串成一条可以直接落地的生产级链路。适合正在做 AI 聊天应用、Agent、RAG 工具链,或者任何需要"一边流式渲染、一边结构化取数"场景的前后端工程师参考,前端为主,涉及后端部分我会同步补原理,两边都能照着抄。
1.1 一次真实的"流式输出 + JSON"现场事故
我先还原一个自己踩过的现场。当时做一个内部数据分析助手,后端用 vLLM 部署模型,前端用fetch拉流式接口。初版代码长这个样子:
const res = await fetch('/api/chat', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ messages, stream: true }) }); const reader = res.body.getReader(); const decoder = new TextDecoder('utf-8'); while (true) { const { done, value } = await reader.read(); if (done) break; const chunk = decoder.decode(value); // 天真地以为每个 chunk 都是一个完整的 JSON const data = JSON.parse(chunk); // 抛错,整个前端逻辑直接崩 }第一次跑就哐哐报错。为什么?因为流式接口返回的每个 chunk,本质上只是 HTTP 响应体里新抵达的一段未定边界的字节流。网络包从哪里切,取决于传输层、代理和缓冲区状态,跟模型输出内容的语义毫无关系。模型打算输出{"city":"上海"},第一个 chunk 完全可能是{"city":"上,第二个 chunk 才是海"}。在这种前提下对每个 chunk 单独做JSON.parse必然失败,这是流式的物理特性,不是配置问题。
我当时的第一反应是"攒起来,流结束再解析",但很快发现事情没那么简单。用户需要在模型还没说完的时候就看到文字在动,这是产品体验的底线;可一旦把"显示"和"解析"混在一个回调里处理,你会发现显示层能接受半句话,解析层却不能接受半个 JSON。这两条路径从一开始就该分开设计,后面我在第六部分会给出完整拆分方案。
1.2 chunk 边界与 UTF-8:祸根在一开始就埋下了
第一次写流式的人还会栽在另一个更隐蔽的坑里:多字节字符被从中间切断。比如"上海"的"海"在 UTF-8 编码下占三个字节,如果某个 chunk 恰好只包含它的前两个字节,直接decoder.decode(value)就会得到一堆�。解决方式其实一行代码:
let text = ''; while (true) { const { done, value } = await reader.read(); if (done) break; text += decoder.decode(value, { stream: true }); } text += decoder.decode(); // 冲刷缓冲区,把残留的半截字符收尾关键是这个{ stream: true }选项。它的作用是把解码器内部残余字节缓存起来,等下一个 chunk 到达时再拼接成完整字符。很多人只看到文档里写了这个参数,不知道它到底解决什么问题——它就是专门处理"字节流可以从任意位切割"这件事的。
不过要牢记,TextDecoder只解决编码层面的完整性,它不会帮你把{"city":"上补成完整 JSON。于是问题依然存在:我们拿到了一长串"合法字符",但里面的结构化数据是残缺的。这就是流式输出和结构化解析最核心的矛盾:展示层需要增量,解析层需要完整。想通这一点,后面所有方案都是围绕"如何调和这个矛盾"展开的。
2. JSON 天生脆弱:把解析策略拆成"攒齐"和"边收边补"两层
JSON 协议在设计时从来就没考虑过"流式增量解析"。它的合法性判断依赖你必须读到最后一个}才知道整体对不对,中途任何一个字符错位,整段全废。而流式场景对错误容忍度的要求又特别高,所以第一步不是去找某个神奇库,而是先调整架构预期。
2.1 攒齐再解析 vs 边流边补:两条路线的取舍
我见过不少团队在这里走极端。一端是"永远不解析,等done之后一口气处理",实现简单,缺点也明显:前端只是在"假装流式",因为整个 JSON 要在最后几千毫秒里才能被解析,中间做不了任何联动。另一端是追求极端实时,恨不得模型每生成一个字就更新一次结构化数据,结果被各种边界 case 折磨到怀疑人生。
我的建议是分场景。如果只是给前端展示用,"流式负责把字打出来,攒齐后统一解析"完全够用,代码量最少,也最好维护。如果确实需要"边生成边渲染图表、表格、卡片",那就必须引入增量解析,但这不意味着要上一个完整的流式 JSON 解析器。很多实际场景用"先攒一个局部 buffer,每次只对 buffer 里完整的复合结构做提取"就够了,比如 buffer 里出现了}就尝试JSON.parse一次,失败就继续等。
这里有一个很实用的经验:给解析操作加一个频率下限。不要每个 chunk 都去试解析,而是用节流控制在 100~200 毫秒一次。流式场景下 token 到达速率一般不会太快,100 毫秒的延迟用户完全感知不到,却能省掉一大批无意义的解析失败和异常日志,团队排障时也会清爽很多。
2.2 兜底修复:jsonrepair 与 partial-json-parser 对比
决定"边收边补"之后,光靠JSON.parse不够,因为它在残缺 JSON 面前是一票否决。这里有两个主流思路,我结合自己的使用体验整理成表:
| 方案 | 原理 | 适用场景 | 短板 |
|---|---|---|---|
攒齐后JSON.parse | 完整数据一次性解析 | 简单场景、低并发 | 中间完全拿不到结构化数据 |
jsonrepair | 对残缺/非法 JSON 做启发式修复 | 差一点就能解析的脏数据 | 结构缺失严重时无能为力 |
partial-json-parser | 流式 token 级解析,返回已完整部分 | 需要提前渲染嵌套结构 | 实现复杂、边界 case 多 |
| JSON Mode / 约束解码 | 服务端解码器强制产出合法 JSON | 后端可控、追求稳定 | 依赖云端服务或 vLLM 等框架 |
jsonrepair是我放在最后一层兜底用的,它的修复能力覆盖了模型输出里绝大多数脏数据:多余逗号、缺少括号、键名没引号、字符串里裸单引号、末尾被截断的数组/对象等等。用法非常简单:
import { repair } from 'jsonrepair'; function safeParse(raw) { try { return { ok: true, data: JSON.parse(raw) }; } catch { try { return { ok: true, data: JSON.parse(repair(raw)) }; } catch { return { ok: false, error: 'json-repair-failed' }; } } }注意我是"整段攒齐之后"才做 repair,而不是流式过程中做。原因很直白:jsonrepair面向的是"基本完整的残缺数据",如果数据只流到一半,缺失的可能是整整一个对象,任何启发式都不可能猜出模型后面要说什么,硬修只会产出更离谱的错误数据。所以流式过程中不要修,等流结束再修;partial-json-parser这种流式方案则用来满足中间态渲染,两者不是替代关系,而是各自管好各自那一段。
2.3 治本的一招:JSON Mode 与约束解码
前端写得再花哨,都不如从源头掐断问题。如果后端是自己部署的模型(比如 vLLM 或本地 Ollama),优先开启约束解码。这也是"vllm部署大模型"相关话题里被反复提到的 key point:vLLM 的guided_json可以让模型在解码阶段就按照给定 JSON Schema 逐 token 约束输出,把"合法性"前置到 token 采样阶段,从机制上杜绝语法错误。
from vllm import SamplingParams sampling_params = SamplingParams( temperature=0, guided_json={ "type": "object", "properties": { "city": {"type": "string"}, "temperature": {"type": "number"}, }, "required": ["city", "temperature"], }, ) outputs = llm.generate(prompt, sampling_params)如果调用云端大模型,OpenAI 和 Anthropic 也都有 JSON mode(OpenAI 是response_format: { "type": "json_object" },Anthropic 类似)。这类模式的核心价值是让模型在解码每个 token 时只能选择"能继续构成合法 JSON"的 token,从源头消灭不完整和语法错误。
但这里必须泼一盆冷水:JSON Mode 只保证语法合法,不保证结构正确。模型完全可以给你返回一个合法 JSON——比如{"city": 123}——但你的业务要求 city 是字符串。JSON Mode 约束的是形式,约束不了语义。这就会把问题带到更深的层次:我们需要在"能解析"和"符合预期"之间加一道闸,这就是第三部分要讲的 Zod。
3. Zod 兜底:JSON.parse 成功不等于数据能用
很多工程团队把"能 JSON.parse 出来"当成"拿到了结构化数据",然后一头扎进data.city.someField,直到线上暴露出undefined is not a function才意识到问题。大模型不是遵守类型契约的开发者,它生成的字段随时可能缺、多、类型错。所以解析成功之后必须立刻做 schema 校验。
3.1 为什么结构校验必须独立于语法解析
我见过一个真实案例:某个 Agent 应用让模型返回工具执行结果,模型在正常输出之外加了一个多余的顶层字段,导致前端按字段名取值永远拿到 undefined,而且因为 JSON 本身合法,后端日志里根本查不到异常。这就是典型的"语法合法但结构不对"。如果一开始就引入 schema 校验,这种问题在上游就直接被拦住了。
在 Node/TypeScript 生态里我首选 Zod,原因有三。一是和 TypeScript 类型推断无缝衔接,z.infer<typeof Schema>直接得到静态类型,一份 schema 两处用;二是safeParse不抛异常,错误信息结构规整,方便构建修正提示;三是生态成熟,和 OpenAPI、tRPC 都能互通。如果后端是 Python,同等定位是 Pydantic,思路完全一致。
下面这段是给 Tool Calls 参数校验用的 Zod schema 示例,后面第四部分还会继续用:
import { z } from 'zod'; const GetWeatherArgs = z.object({ city: z.string().min(1, '城市名不能为空'), days: z.number().int().min(1).max(7).optional(), unit: z.enum(['celsius', 'fahrenheit']).default('celsius'), }); type GetWeatherArgs = z.infer<typeof GetWeatherArgs>;注意unit用了default('celsius'),这个能力很关键。模型经常漏掉可选项,如果没有默认值,你就得在业务代码里到处写args.unit ?? 'celsius';有了 default,校验通过后的数据就是"自带默认值"的最终值,能少掉一大片判空代码。
3.2 safeParse 不等于"吞错误",错误信息要变成修正弹药
safeParse的返回值是一个 discriminated union:
const result = GetWeatherArgs.safeParse(rawJson); if (!result.success) { // result.error 是 ZodError,里面是 issues 数组 const summary = result.error.issues .map((issue) => `路径 ${issue.path.join('.')}:${issue.message}`) .join(';'); console.error('参数校验失败:', summary); } else { // result.data 已经是类型安全的数据 const { city, days, unit } = result.data; }我特别想强调"不要吞错误"。很多同学校验失败就直接 return null,前端弹一个"解析失败"的框,这是最浪费的做法。Zod 给出的错误信息是一份非常宝贵的修正指引:路径 city:城市名不能为空这种信息,完全可以原样拼到下一轮对话里,让模型自己把输出改对。这是自修正循环能生效的唯一前提——模型必须知道它错在哪。
3.3 自修正循环:让模型自己把输出改对
流式输出加大模型本身存在的偶发错误,决定了你不可能要求"一次生成、一次成功"。行业里最务实的做法是给模型一两次纠错机会。核心逻辑是一段循环:
async function generateValidated<T>( messages: ChatMessage[], schema: z.ZodType<T>, maxAttempts = 3 ): Promise<T> { for (let attempt = 0; attempt < maxAttempts; attempt++) { const raw = await streamOnce(messages); const json = safeParse(raw); // 内含 jsonrepair 兜底 if (!json.ok) { messages.push({ role: 'assistant', content: raw }); messages.push({ role: 'user', content: `你的上一条输出无法被解析为合法 JSON,请只输出原始 JSON,不要任何说明文字。报错:${json.error}`, }); continue; } const checked = schema.safeParse(json.data); if (checked.success) return checked.data; messages.push({ role: 'assistant', content: raw }); messages.push({ role: 'user', content: `你输出的内容通过了 JSON 解析,但不符合要求。错误明细:${summarizeZodError(checked.error)}。请重新生成。`, }); } throw new Error('超过最大重试次数'); }这里有三个必须注意的细节。第一,每次修正尝试要把"上一条原文"以assistant消息形式放回对话历史,模型才能定位到自己刚才的输出;第二,修正提示必须具体到字段和原因,笼统说"你错了"基本没有效果;第三,maxAttempts一定设上限,我默认给 3 次,超过就降级到用户可见的兜底提示,避免模型陷入无限自我否定。另外我还会记录每一轮失败原因,这些日志是最便宜的回归测试集,攒多了之后你会非常清楚自己接的模型容易在哪里翻车。
4. Tool Calls 全面实战:流式增量拼装与参数级校验
聊完 JSON 和 Zod,接下来是重头戏:Tool Calls,也常叫 Function Calling。它和"让模型返回一段 JSON"最大的区别在于,Tool Calls 是模型协议层面对"调用外部函数"的一等公民支持,而不是纯文本约定。很多教程只教你"怎么发一次请求",但流式场景下的 Tool Calls 有很多自己的坑,尤其是增量拼装。
4.1 Tool Calls 本质上是一条独立通道
先看一次非流式响应里 Tool Calls 长什么样:
{ "choices": [{ "finish_reason": "tool_calls", "message": { "role": "assistant", "content": null, "tool_calls": [{ "id": "call_abc123", "type": "function", "function": { "name": "get_weather", "arguments": "{\"city\":\"上海\",\"days\":3}" } }] } }] }注意三个关键点。第一,content是null,当模型决定调用工具时,通常不再输出给用户的正文;第二,arguments是一个字符串化的 JSON,你拿到之后还得再JSON.parse一次,这就是"双重解析"的由来;第三,finish_reason是tool_calls,业务层要靠它判断"接下来该执行工具而不是继续对话"。这三个点都理解到位,再写流式处理才不会慌。
4.2 流式增量拼装:千万不要在 delta 上直接 JSON.parse
流式响应里,Tool Calls 的信息是碎片化推送的。OpenAI 兼容协议中,每个 chunk 的delta里可能只有tool_calls数组的某个片段,而且按 index 区分:
{"choices":[{"delta":{"tool_calls":[{"index":0,"id":"call_abc123","function":{"name":"get_weather","arguments":""}}]}}]} {"choices":[{"delta":{"tool_calls":[{"index":0,"function":{"arguments":"{\"city\":"}}]}}]} {"choices":[{"delta":{"tool_calls":[{"index":0,"function":{"arguments":"\"上海\"}"}}]}}]}也就是说,arguments会被拆成好几段字符串,每段单独JSON.parse必然失败。正确姿势是按 index 把碎片拼起来,等整个流走完、finish_reason变为tool_calls之后再整体解析:
const accum: Record<number, { id?: string; name?: string; args: string }> = {}; // 每个 chunk 到达时的处理 function onChunk(chunk: OpenAIStreamChunk) { const tc = chunk.choices?.[0]?.delta?.tool_calls?.[0]; if (!tc) return; const index = tc.index ?? 0; accum[index] ??= { args: '' }; if (tc.id) accum[index].id = tc.id; if (tc.function?.name) accum[index].name = tc.function.name; if (tc.function?.arguments) accum[index].args += tc.function.arguments; } // 流结束时统一处理 function finalizeToolCalls() { return Object.entries(accum).map(([index, item]) => { const parsed = safeParse(item.args); // 还是那套 jsonrepair 兜底 return { index: Number(index), id: item.id, name: item.name, arguments: parsed }; }); }这段代码有两个容易写错的点。第一,accum[index] ??= { args: '' }必须放在处理 fragments 之前初始化,否则第一个带 arguments 的 chunk 到来时args还不存在,+=会直接变成"undefined...";第二,同一个 index 的id和name只在第一个 chunk 出现一次,后面全是纯 arguments 片段,所以每个字段要单独判断,不能合并成一个if全包进去。
4.3 tool_choice 强制单工具:把复杂度降一半
如果你的业务场景一次只会调用一个工具(大多数 Agent 的初始化阶段都这样),我强烈建议用tool_choice把行为钉死:
{ "tools": [{ "type": "function", "function": { "name": "get_weather", "description": "查询指定城市未来若干天的天气", "parameters": { "type": "object", "properties": { "city": { "type": "string" }, "days": { "type": "integer" } }, "required": ["city"] } } }], "tool_choice": { "type": "function", "function": { "name": "get_weather" } } }这样模型不会在"要不要调用工具"和"调用哪个工具"之间犹豫,流式响应里只会出现 index 0 这一条 tool_call,增量拼装逻辑可以砍掉一半。等业务发展到"模型需要自己选工具"时,再把tool_choice换成"auto"。到那时多个 tool_call 并行出现的概率会明显上升,前面那套按 index 累加的代码才算真正派上用场。
顺带提醒一句,有时候你会看到模型给了finish_reason: "tool_calls",但某个 tool_call 的 arguments 拼完后依然是空字符串或残缺的。这种情况在低温度下也偶有发生,最稳妥的处理是把这当成一次"参数生成失败",走 Zod 校验失败那条路回灌给模型重新生成,而不是自作主张填默认值。
4.4 工具参数的 Zod 化:从 prompt 定义到运行时校验共用一份 Schema
Tool Calls 的parameters本质上是一份 JSON Schema,它既会被放进请求里指导模型,也应该在运行时被拿来校验模型的实际输出。与其前后端各维护一份,不如让一份 Zod schema 成为唯一数据源,再生成 JSON Schema 给请求用。Zod 生态里有zod-to-json-schema这类工具:
import { zodToJsonSchema } from 'zod-to-json-schema'; const GetWeatherArgs = z.object({ city: z.string().min(1), days: z.number().int().min(1).max(7), }); // 一份 schema,两种用法 const jsonSchema = zodToJsonSchema(GetWeatherArgs, 'get_weather_args'); const checked = GetWeatherArgs.safeParse(parsedArguments);这个做法的收益在长期维护上尤其明显:以后改参数、加字段、调枚举,只需要改一处,不会出现"文档里写 city 必填,运行时检查却漏了"的脱节。运行时校验通过后,再放心把checked.data传给实际的函数执行器。
5. 热搜词实战:标签返回未完整怎么处理
做流式结构化解析的人应该都搜过"标签返回未完整怎么处理"这个问题。它最常见的来源是:你用大模型输出 XML(比如 Claude 系的<output>...</output>风格),或者提示词里让模型用<tag>...</tag>包裹结构化内容,结果流式输出时标签对一直闭合不完整。这里把处理思路完整梳理一遍。
5.1 2025 年了,为什么 XML 仍然有它的位置
你可能会问,JSON 都研究得那么透了,为什么还要用 XML?原因很现实:对于"长文本中嵌入结构化片段"的场景,JSON 的可读性和可修复性都远不如带标签的 XML。比如让模型返回一篇带多个小节的文章,JSON 的转义符会铺满全文;而<section><title>...</title><body>...</body></section>这种结构,模型生成时容错率高得多,人在调试时一眼也能看懂。Anthropic 官方也很早就推荐用 XML 标签来组织提示词输出,Claude 系列模型对这种格式的遵循度普遍很高。
所以在 RAG、长报告生成这类场景里,"XML 输出 + 标签解析"是比"JSON 输出 + 流式校验"更省心的组合。代价就是得自己处理"标签未完整"这个流式带来的副产品。
5.2 标签未完整的标准姿势:事件式拼接,而不是正则硬啃
我见过最惨烈的写法是拿一个巨型正则去匹配流中每个 chunk 里的<tag>...</tag>。结果不用想:标签被切成两半时正则直接失效,而且正则面对嵌套标签时极易产生灾难性回溯。正确做法是"事件式拼接"——维护一个缓冲区,持续扫描,遇到闭合的完整标签就提取并触发回调:
class XmlTagStreamParser { private buffer = ''; private stack: string[] = []; feed(chunk: string, onComplete: (tag: string, text: string) => void) { this.buffer += chunk; // 持续扫描 buffer,把完整的 <tag>...</tag> 提取出来 // 简化示例:只处理无嵌套的平铺标签 const regex = /<([a-zA-Z_][\w-]*)>([\s\S]*?)<\/\1>/g; let m: RegExpExecArray | null; while ((m = regex.exec(this.buffer)) !== null) { onComplete(m[1], m[2]); this.buffer = this.buffer.slice(m.index + m[0].length); regex.lastIndex = 0; // buffer 被重写了,重置游标 } } remaining(): string { return this.buffer; } }这个简化版只覆盖平铺标签。真实项目里如果标签会嵌套,就得改成真正的栈式解析:开标签入栈,闭合标签出栈,只有栈空时才算一个完整块。无论哪套写法,核心原则一致:不要试图解析"当前这一小段",而是维护累积缓冲区,等完整结构出现再动手。这跟第四部分流式拼装 arguments 的思路是同一个世界观。
另外别忽略一个细节:模型输出的 XML 内容里可能有<这样的转义实体,也可能出现花括号等和 JSON 冲突的字符。我的习惯是在提取完整标签文本后,做一次"解除转义 + 剔除控制字符"的清洗,再进业务逻辑,否则后续入库或渲染时会出现莫名其妙的错位。
5.3 流结束时仍然缺闭合标签怎么办
最麻烦的情况是流结束了,栈里还有未闭合的标签。我按优先级给出一套降级策略:
- 如果标签内容是完整可用的(比如
<title>周报</title>已经出现,只是后面还有个<body>没闭合),直接丢弃未闭合标签,采用已完整内容; - 如果栈里只有一个标签且内容明确,可以按人工规则补上闭合标签,比如模型输出到一半的
<summary>本周完成三项任务,栈里压着summary,那就补一个</summary>再解析; - 如果嵌套两层以上且截断位置模糊,就不要硬补了,优先保数据正确性,把整块标记为"不完整",走重试或交给用户确认。
这里最忌讳的是"不管三七二十一全部补闭合标签"。XML 标签的合法性跟 JSON 一样,补错一个闭合位置,结果比不补更糟。判断原则是:内容语义已经明确的才值得补;内容本身是截断的半句话,补了也是垃圾数据。
6. 把整个链路串起来:生产级流水线与我沉淀的经验
前面几部分是单点拆解,这一部分把它们串成一条能在生产环境跑的流水线,并把那些只会在真实部署中踩到的细节一一列出来。我会以"解析一个需要调用工具的流式响应"为例,因为它覆盖了 JSON、Zod、Tool Calls 三条主线,XML 场景按第五部分的处理方式接入即可。
6.1 一条完整的解析流水线
流式响应进入 -> 展示层:chunk 文本直接追加到界面(TextDecoder stream: true) -> 解析层:按 index 累加 tool_calls 的 delta -> 流结束 -> finish_reason == 'tool_calls' ? 拼装 arguments : 使用 content -> JSON.parse(失败走 jsonrepair 修复) -> Zod safeParse -> 失败:把 Zod 错误回灌,重试(最多 3 次) -> 成功:调用真实工具,把结果作为新消息继续对话这条流水线的关键设计是"展示层和解析层完全分离"。展示层永远不会因为 JSON 解析失败而卡顿,解析层也不会因为要迁就展示而被迫处理半截数据。两边的关注点完全不同:显示关心快不快、顺不顺,解析关心对不对、全不全。
6.2 失败场景与降级策略对照表
我把实际运行中遇到的高频失败场景整理成一张表,建议直接贴到团队文档里:
| 失败场景 | 表现 | 处理策略 |
|---|---|---|
| 流式 chunk 乱码 | 中文变成� | TextDecoder加{ stream: true },结束后再 flush 一次 |
| 积累的 JSON 不完整 | Unexpected end of JSON input | 流结束后用jsonrepair修复,修复失败标记不完整 |
| JSON 合法但 Schema 不符 | 字段缺失/类型错误 | ZodsafeParse拦截,错误信息回灌重试 |
| tool_calls 的 arguments 残缺 | 拼完仍是空/半截 | 按 index 拼装后仍失败,走参数重生成兜底 |
| XML 标签未闭合 | 栈里有剩余标签 | 按"内容语义明确才补"原则分层降级 |
| 超过重试上限 | 连续 3 次失败 | 返回用户可见的兜底提示,记录日志 |
这张表的价值不在于"处理策略"那一列有多新奇,而在于每一行的判定条件都很具体,新接手的人不用靠猜就知道该走哪条路。
6.3 踩坑之后留下的个人心得
这几条是我自己反复踩过之后写进团队规范里的,不保证绝对正确,但至少能帮你少摔几次。
第一,永远给流式链路上限。无论是 token 数、buffer 大小还是重试次数,都必须有硬上限。模型偶发话痨是常态,没有上限的循环会在某次线上事故里给你上一课。第二,日志里永远保留原始输出。我已经数不清有多少次靠"原始 JSON 长什么样"才定位到是前端拼装错了还是模型输出错了。第三,接入新模型前先跑一轮坏样本回归。把你积攒的失败案例喂给新模型,看它是不是同样翻车,能提前发现一个模型的"性格缺陷",比上线后再救火省心得多。第四,修正提示一定要具体。我观察到的规律是,告诉模型"你的 city 字段不是字符串"比告诉它"你返回的数据不符合要求"有效得多,成功率能差出一大截。
说到底,流式输出加结构化解析这件事,本质是在跟不确定性共存。模型不会因为你在前端写了更漂亮的代码就变得百分之百可靠,但你可以通过"约束、校验、回灌、降级"这一整套机制,把不可靠性控制在产品可接受的范围内。我现在的默认态度是:把每一次解析都当成可能失败来处理,代码写得更悲观一点,线上反而更稳。希望这份实战记录能帮你少走一些我走过的弯路。