ByteNoteByteNote
事件流里的换行,为什么 JSON 解析会失败
字

字节笔记本

2026年10月7日 · 约 11 分钟读完

事件流里的换行,为什么 JSON 解析会失败

API中转
¥120

接口把回答做成事件流往外推。每一段以 event: 开头,后面跟一行 data:,数据是 JSON。事件名里能看到四种:conversation.message.delta 是增量,conversation.message.completed 是这一条消息完成,conversation.chat.completed 是整次对话完成,最后还有一个 done。前端要的通常不是每一帧增量,而是完成后的正文,再从正文里拆出标题和内容。

四种事件里只取完成帧的正文

先按事件切开,再按类型取

原始文本不能直接 JSON.parse,因为里面夹着 event: 和 data:。第一版按 event: 切开,再按 data: 切开,事件名做 trim 之后进分支:

javascript
function parseEventStream(data) {
  const events = data.split("event:")
  const result = { messages: [], completion: null, done: false }
  for (const event of events) {
    if (!event.trim()) continue
    const [eventType, ...dataParts] = event.split("data:")
    const eventName = eventType.trim()
    const jsonData = dataParts.join("data:").trim()
    if (eventName === "conversation.message.delta" || eventName === "conversation.message.completed") {
      const messageData = JSON.parse(jsonData)
      result.messages.push({
        id: messageData.id,
        content: messageData.content,
        role: messageData.role,
        type: messageData.type,
      })
    } else if (eventName === "conversation.chat.completed") {
      result.completion = JSON.parse(jsonData)
    } else if (eventName === "done") {
      result.done = true
    }
  }
  return result
}

data: 可能在 JSON 字符串里再出现一次,所以数据段用 split 之后再 join("data:") 拼回去,不能只取第一段。只要标题和正文时,不必收集每一个 delta。完成事件里的 content 已经是整段,type === "answer" 再收下,增量帧丢掉,避免把半截文本拼进结果。

空结果往往是看错了事件

有一版解析函数只在文本里找 message.completed,控制台却一直打印“无内容”。原因不一定是流是空的。增量事件的 content 经常是空白或一个词,完成事件才带全文。如果样本被截断,完成事件根本没进函数,循环会正常结束,返回空标题和空正文。

排查时把每一段的前 100 个字符打出来,确认事件名是不是 conversation.message.completed,再确认 data: 后面是不是以 { 开头。事件名多一个空格、或者完成事件被包在别的前缀里,includes("message.completed") 可能误伤别的事件,也可能因为你切分时把名字切丢了而永远匹配不到。

控制字符:JSON.parse 拒绝原文里的换行

真正把解析打停的是这一句:SyntaxError: Bad control character in string literal in JSON,位置在第 150 个字符附近。JSON 字符串里不允许裸的换行、回车、制表符以外未转义的控制字符。模型返回的正文里有真实换行,如果服务端把这些换行原样塞进 JSON,而没有写成 \n,JSON.parse 就会在那个位置停住。报错列号对得上正文里的第一处换行。

对话里试过一条危险的退路:用 eval("(" + jsonStr + ")") 去解析。不要这么做。事件流来自网络,eval 会把数据当脚本执行。控制字符问题也说明这段文本已经不是合法 JSON,硬执行只会更难判断后面还有没有别的内容。

另一条退路是把控制字符删掉再解析:

javascript
jsonStr = jsonStr.replace(/[\u0000-\u001F\u007F-\u009F]/g, "")
const jsonData = JSON.parse(jsonStr)

这样能过 JSON.parse,但正文里的换行也一起没了。标题和正文靠换行来分界时,分界会消失,正则就拆不出“标题”和“正文”。删字符是为了过解析,不是为了保留版式。

要留住换行,应该在字符串内部把裸的控制字符转义成 JSON 转义序列,而不是删掉。更稳的做法是让服务端发出合法 JSON,换行一律写成反斜杠加 n。前端只做一件事:按 SSE 的空行分帧,取出 data: 后面的整段,确认它能被 JSON.parse 接受。修不好的帧打日志,不要吞进结果。

从正文里拆标题时,对话里用的是两段正则,一段吃“标题”下一行,一段吃“正文”直到“标签”或结尾。这假设模型按这个小标题输出。模型如果换了版式,正则会返回空字符串,看起来又像解析失败。先把 content 原样打印出来,再写正则。

分帧、解析、拆字段是三步

事件流先按 event: 分帧,只从 conversation.message.completed 取完整 content。JSON.parse 报控制字符,就是帧里的 JSON 不合法,优先让上游转义换行,不要用 eval,也不要先把换行删掉再抱怨标题拆不出来。字段拆分放在解析成功之后,单独看正文长什么样,再写正则。

控制字符和切帧坏了是两种报错

分帧方式和正则假设

按 event: 切开能工作,是因为样本里事件名都写在块首。JSON 字符串如果也包含这五个字符,切分会把一条消息切成两段,后半段的事件名变成乱码,然后在 JSON.parse 上报另一个错误。更稳的分帧是按 SSE 的空行切开,每一帧里只认行首的 event: 和 data:。data: 可以有多行,按规范要拼起来再解析。对话里的实现把 data: 再次出现的部分拼回去,就是在补这个洞,只是补在了 split 上,没有按行处理。

完成事件里的正文字段,对话里的样本是带换行的长文本,前面有“标题”“正文”这样的标记,末尾可能有“标签”。提取用的是两段正则:一段取标题下一行,一段取正文直到标签或字符串结束。正则写死了这个版式。模型如果把标题写成 Markdown 的井号,或者根本不分节,函数会返回空标题和空正文,日志却显示 JSON 已经解析成功。这时候去改 split 没有用,要改的是字段提取。

增量事件不适合做这个提取。delta 的 content 在样本里可以只是一个空格。把每一帧的 content 拼起来,才能得到全文,但只要中间丢一帧,全文就缺一块。completed 已经给出全文时,就只信这一帧,并用 type === "answer" 排除别的完成消息,例如跟在后面的建议问句或工具状态。conversation.chat.completed 放进 completion,done 只把 done 标成真。三个信号不要混成一个布尔值,否则你无法区分“消息完成了”和“流结束了”。

控制字符的报错位置大约在第 150 个字符,说明前面的 JSON 结构是好的,坏在字符串中部。这和括号不配对不一样。括号错误通常报在结尾或少一个逗号的位置。看到 Bad control character,就去那个下标附近找换行或制表符,而不是去改事件名分支。

如果必须在前端补救不合法的 JSON,只转义字符串内部的控制字符,转义之后再 JSON.parse。转义结果应该和“原文里写了反斜杠 n”等价,标题和正文之间的换行还在。删除控制字符的那条正则能让解析通过,但会让后面的换行正则失效,两个修复会互相拆台。eval 则不要出现在任何正式代码里,它既不保证能接受裸换行,又把整段网络数据变成了可执行代码。 测试时不要只用截断的几行。对话里有一次样本只含增量事件,完成事件不在里面,函数按设计返回空。把完整的一串,从第一条 event: 到最后的 done,放进控制台再跑。中间用日志打印事件名和 data: 后前 100 个字符,就足以看出是分帧错了,还是 JSON 错了,还是正则没匹配上。

标题正则依赖“标题”两个字和它后面的换行。正文正则依赖“正文”以及结束标记“标签”。这些标记是某一类文案模板,不是事件流协议的一部分。协议只保证 content 是字符串。模板变了,解析函数仍应返回原始 content,再由上层决定怎么拆。把模板写死在解析函数里,模板一改,调用方会以为流断了。

dataParts.join("data:") 这句值得留着。模型正文里如果提到了 data: 这个词,简单的“取第一段”会把 JSON 后半截切掉,JSON.parse 报的是意外结尾,而不是控制字符。两种报错对上两种损坏:意外结尾多半是切坏了,控制字符多半是换行没转义。看错报错类型,修的地方就会反。

相关文章

分享: