# Peri Code 流式协议踩坑——从中文乱码到 400 报错

> **[Peri Code](https://github.com/konghayao/peri)** — 用 Rust 写的开源 Coding Agent，兼容 Claude Code 生态。<https://github.com/Konghayao/peri>

Peri 的流式回答里偶尔会冒出 `�`，比如「问题�述」。这不是模型输出了错字，是网络把模型的回答分成一小块一小块传过来时，切的位置刚好落在一个中文字的中间，这个字就显示不出来了。同期还出现两个 bug，写文件时报找不到文件名，下一轮对话直接报错 400。

这三个 bug 发生在数据流转的不同环节，表现完全不同，但根因相同。模型的回答从网络传到 Agent 能用的结果，要经过三个环节，每个环节都有一个「完整性」标记，告诉程序「到这里数据是完整的」。问题在于，流式传输里这三个标记都会失效。

## 流式数据的三层完整性边界

第一层是字节。网络不会把整段回答一次性送过来，而是切成许多小块（chunk）逐个送达，切块的位置是网络随机决定的，可能恰好把一个中文字的字节序列切开。

第二层是 token（模型处理文字的基本单位，大致一个词或半个汉字）。模型一次能输出的 token 数有上限（常见的 4096），超了就会被截断，截断点可能落在一个结构化参数的任意位置。

第三层是元数据。模型在回答里会带一个 stop_reason 字段，声明自己为什么停下来——是回答完了（end_turn），还是要调工具（tool_use）。但这个声明有时和回答的实际内容对不上。

三层各自有完整性保证，三层都可能失准。

## 跨 chunk 边界的 UTF-8 多字节解码

模型回答走的是 SSE 协议，一种服务器按行推送数据的格式，每行一条 `data:`，空行分隔事件。Peri 用 reqwest 接收这些数据块，交给一个解析器把完整事件切出来。

麻烦出在切块位置。中文字在计算机里不是一字一节，一个中文字要占好几个字节——「描述」两个字要占 6 个字节，每个字 3 个。如果网络把数据切在前一个字的字节序列中间，比如前一个块带了「描」字的前两个字节，后一个块才补上第三个字节，那么前一个块单独看就是半个字。

早期的解析器用 Rust 的 `from_utf8_lossy` 把每个块直接转成文字再拼起来。这个函数名字里的 lossy 是有代价的——碰到不完整的字节序列（半个字），它不会等后续数据，而是直接用一个占位字符 `�`（U+FFFD）顶替，然后把替换后的文字交出来。顶替发生的那一刻，原始的两个字节就被丢掉了。等下一个块带着剩下的字节到达，已经没有信息能把它还原回「描」了，因为这个位置已经被 `�` 占了。

```
块1 末尾: "问题" + 描的前两字节（不完整）
from_utf8_lossy 转换: "问题�"   ← 半个字被问号顶替，原始字节永久丢失
块2 开头: 描的第三字节 + "述"   ← 字节到了也补不回来
```

修复的思路是，不在切块的位置做文字转换，只在换行符的位置做。因为 SSE 是按行推送的，换行符一定落在完整字符的后面，不会把一个字切开。所以解析器把缓存从「存文字」改成「存原始字节」，每来一个块就把字节拼进去，找到最后一个换行符，只对换行符之前的部分做一次文字转换，之后的部分留着等下一个块。这样半个字永远不会被单独转换，也就不会被问号顶替。

这个改动只动了解析器内部和入口的几行代码，对外接口没变，OpenAI 和 Anthropic 两条流式路径都不用改。

## max_tokens 截断破坏 JSON 字段完整性

第二层是 token 上限。Peri 调用 Write 工具写文件时，模型要输出一段结构化的 JSON，里面带 `file_path`（文件路径）和 `content`（文件内容）两个字段。当 `content` 是一篇几千字的内容时，4096 的字数上限会不够用，输出到一半被截断。

这个 bug 复现频率超过 50%，而且不同模型的失败表现不同，暴露出一个关键变量——模型先输出哪个字段。

GLM-5.1 先输出 `content`，把整篇长文写完，闭合了 JSON 对象，字数正好用光。结果是一段格式完全合法的内容，但 `file_path` 这个字段从头到尾没出现过。Write 工具一上来就要取文件路径，取不到，直接报「找不到文件名」。

DeepSeek v4 Pro 反过来，先输出 `file_path`，再写 `content`，内容写到 12000 多字符时字数耗尽，JSON 断在内容字符串中间，整个结构都解析不了。同一个根因（字数不够），两种截然不同的失败，区别只在于模型先写哪个字段。

Peri 的 Write 工具参数表里，`file_path` 本来就排在 `content` 前面。参数表里的顺序只是给模型的提示，不保证模型遵循这个顺序，GLM 就没按这个顺序输出。但这个顺序仍然值得坚持，因为它决定了万一被截断，先丢哪个字段。`file_path` 是关键信息，丢了整次调用就失败；`content` 是内容本身，丢了最多让模型重发一次。让关键字段排前面，把截断的代价降到最小。

光靠字段顺序还不够。字数上限客观存在，遇到超长内容必然截断。真正能根治的是让模型分块写——先写文件路径和开头，再一段段追加后续内容，每段都控制在上限内。Peri 的 Write 工具后来加上了 `append`（追加）选项和对应提示，打破「一次性写完」这个隐含假设。

直接调大 max_tokens 是最直觉的做法，但治标不治本。4096 不够就调到 8192，也只是把阈值往后挪，碰到更长的内容照样截断。真正根治的是字段顺序和分块写入这两个结构性手段。

## stop_reason 与响应内容不一致的路由判定

第三层是元数据。模型在回答里会带一个 stop_reason 字段，声明它为什么停下来——`end_turn` 表示回答完毕，`tool_use` 表示要调工具。Peri 早期的路由逻辑完全靠这个字段决定下一步走哪条路。

DeepSeek 在 Anthropic 兼容端口上返回的 stop_reason 是 `end_turn`，但回答内容里实际带着工具调用。声明和内容对不上。

程序看到 `end_turn`，走了「回答完毕」的分支，把这条带工具调用的消息直接存进对话记录。因为没走工具调用分支，没有任何工具结果配对。下一轮把这条记录发给模型，就触发了 400。

```
API 错误 400: messages.15: 找到 tool_use 但后面没有紧跟 tool_result。
每个 tool_use 必须在下一轮消息里有对应的 tool_result。
```

这是偶发 bug，取决于 DeepSeek 返回的 stop_reason 是否准确。偶发但影响大，一次 400 会中断整个长任务。

修复方式是，在 stop_reason 不是 `tool_use` 的分支里再补一道检查，看回答内容里到底有没有工具调用，有就强制按工具调用来处理，同时打一条警告日志记录这次不一致。

stop_reason 是模型的自我声明，回答里的工具调用是模型实际产出的内容。第三方服务商在兼容层做的转译，会让声明和内容脱节，所以内容优先，实际产出的东西盖过声明。

这个修复和前面的延迟写入重构是配套的。延迟写入保证即使工具执行中途出错，模型消息和所有工具结果也是最后一起写进对话记录，不会出现写了消息却漏掉结果的中间状态。两层防御配合，把「没有配对结果的工具调用」这类问题彻底消除。

## 三层防御的共同结构

这三个 bug 有一个共同结构。每一层都有一个完整性信号——字节层看这段字节能不能转成文字，token 层看这段 JSON 合不合法，元数据层看 stop_reason 字段。程序信任这些信号，信号失准时就出错。

三层防御合起来一条原则，用实际内容覆盖被告知的标记——字节层只在换行符位置转换文字，token 层靠字段排序和分块写入，元数据层用 `has_tool_calls()` 验内容而非声明。

项目地址：[github.com/konghayao/peri](https://github.com/konghayao/peri)
