想让模型返回结构化结果,一边接收一边渲染成卡片列表,常见的写法是在提示词里要一个大对象,形如 {"cards":[{...},{...}]}。后端原样转发 token,前端每收到一块就试着 JSON.parse。结果就是,大部分时间拿到的都是没闭合的字符串,解析直接抛错,到头来只能等最后一个 } 到了才渲染,流式输出等于白做。
网上能搜到的办法大多是写个补全函数,把缺的引号和括号填上再解析。能用,但我觉得毛病出在协议上:界面本来就是一张张卡片,数据为什么非要包成一个整体?
换成一行一条
NDJSON 规范很短:每条 JSON 文本后面跟一个 \n,文本内部不允许出现换行,编码用 UTF-8,解析端要同时接受 \n 和 \r\n,媒体类型建议用 application/x-ndjson。放到这个场景里,就是让后端每生成完一张卡片就输出一行:
{"type":"card","id":1,"title":"第一张卡片"}
{"type":"card","id":2,"title":"第二张"}
{"type":"done","count":2}
每行单独就是合法 JSON,前端按换行切开就能逐条 JSON.parse,半截对象的问题自然没了。需要总数、引用列表这类整体信息时,在最后补一行 done 汇总。
如果是让模型直接写这种格式,后端最好自己按行缓冲,解析成功后再用 JSON.stringify 重新输出一遍。模型偶尔会在字段里塞真换行,或者漏掉引号,这一步能把脏数据挡在服务端。
前端怎么读
response.body 是一个 ReadableStream,读出来是字节块。接一个 TextDecoderStream 就能转成字符串流,解码器在块与块之间保留状态,一个汉字的三个字节被拆到两块里也能正确拼回来。剩下要自己处理的有两件事:块的边界跟行对不上,最后一行可能没有换行。
export async function* readNdjson(response) {
if (!response.ok || !response.body) {
throw new Error(`HTTP ${response.status}`);
}
const reader = response.body
.pipeThrough(new TextDecoderStream())
.getReader();
let buf = '';
while (true) {
const { value, done } = await reader.read();
if (done) break;
buf += value;
let nl;
while ((nl = buf.indexOf('\n')) !== -1) {
const line = buf.slice(0, nl);
buf = buf.slice(nl + 1);
const obj = parseLine(line);
if (obj !== undefined) yield obj;
}
}
// 流结束时 buf 里可能还剩最后一行(服务端没补换行)
const last = parseLine(buf);
if (last !== undefined) yield last;
}
function parseLine(raw) {
const line = raw.trim(); // 顺手去掉 \r
if (!line) return undefined;
try {
return JSON.parse(line);
} catch (err) {
console.warn('跳过坏行:', line.slice(0, 80));
return undefined;
}
}
有几处细节容易漏:
buf里只留最后一个换行之后的内容,这段是还没收全的那一行,等下一块到了再拼。- 读到
done以后要把buf再处理一次。规范要求每条后面都带\n,但服务端漏掉末尾换行的情况并不少,不处理就会丢掉最后一条。 - 坏行跳过并打日志,一行脏数据不至于让整个流崩掉。规范的意思是遇到无法解析的文本应当报错,空行可以静默忽略,所以跳过坏行属于业务上的取舍,团队里统一好就行。
trim()会顺带去掉行尾的\r,\r\n也就兼容了。
渲染和工具调用
const pending = [];
let scheduled = false;
function enqueue(card) {
pending.push(card);
if (!scheduled) {
scheduled = true;
requestAnimationFrame(() => {
scheduled = false;
for (const c of pending.splice(0)) renderCard(c);
});
}
}
async function run(url, signal) {
const res = await fetch(url, { signal });
const toolCalls = [];
let summary = null;
for await (const msg of readNdjson(res)) {
if (msg.type === 'card') enqueue(msg);
else if (msg.type === 'tool_call') toolCalls.push(msg);
else if (msg.type === 'done') summary = msg;
}
if (!summary) throw new Error('流提前断了,没等到 done');
for (const call of toolCalls) await runTool(call);
return summary;
}
卡片先进队列,用 requestAnimationFrame 合并成每帧最多一次 DOM 更新,后端一秒推几十行也不会每来一行就重排。tool_call 只收集不执行,等 done 那行到了才统一跑;收不到 done 就说明流半路断了,这时宁可报错,也别拿着不完整的结果去调接口、写数据库。用户点停止时调用 AbortController 的 abort(),读取会以异常结束,走同一个错误分支。
两段代码在 Node 里也能跑通,只是那边没有 requestAnimationFrame,测试时拿 setTimeout 顶一下就行。用一个每次只发 7 字节的本地服务测过,中文被切断、坏行、末尾缺换行这几种情况都能正常处理。
拆不开的情况
这套做法适合结果天然能拆成多条的场景,比如列表、卡片、搜索结果、多步执行日志。如果界面就是一个嵌套很深的大对象,又要求字段边生成边显示,拆行帮不上忙,还是得上增量 JSON 解析器。另外模型偶尔不按格式输出,服务端那层校验省不掉。
你们接流式的结构化输出时,是改协议,还是在前端硬补括号?有没有碰到怎么都拆不成多行的数据形状?