
一段 5292 字符的 JSON 返回,塞进上下文之前通常要截断。三种常见截法跑下来,只有一种还能解析。
完整返回: 5292 字符
截到 2000 个字符/字节的结果:
A 按字符切 2000 字符 JSON 可解析: 否 替换符 U+FFFD: 0 末尾 24 字: ",状态 pending。\"},{\"id\":100" (Expected ',' or '}' after property value in JS)
B 按字节切 1158 字符 JSON 可解析: 否 替换符 U+FFFD: 0 末尾 24 字: "\"detail\":\"客户端 8 秒放弃,服务端继" (Unterminated string in JSON at position 1158 ()
C 按结构裁 + 省略标记 1619 字符 JSON 可解析: 是 替换符 U+FFFD: 0 末尾 24 字: "00000011,状态 pending。\"}]}"
现象:模型说字段不存在
一次工具调用返回了查询结果,模型在回答里说这个字段没有值。工具的输出里明明有。把送进上下文的那一段单独打出来,才发现末尾停在 {\"id\":100 这里:截断点落在两条记录中间,最后一条是半截记录。
模型看到的是一个语法不完整的 JSON 片段。它对不完整的结构通常会自己补全一个合理解释,缺字段这件事就被解释成"数据里没有"。同一段数据在完整状态下解析没问题,一截断,问题变成模型侧的字段缺失,排查方向从一开始就偏了。
影响面:三种截法都留不下省略的痕迹
A 和 B 两种写法的共同点是:截断这件事只存在于代码里,返回值本身没变样。末尾那 24 个字看起来像数据自然结束,没有任何标记说明后面还有。
--- 模型那一侧看到的是什么 ---
A 的结尾: "8 秒放弃,服务端继续执行;订单号 PO1000000000000014,状态 pending。\"},{\"id\":100"
B 的结尾: "上游超时后重试了两次,日志里只留了一条\",\"owner\":\"值班-1\",\"detail\":\"客户端 8 秒放弃,服务端继"
C 的结尾: "l\":\"客户端 8 秒放弃,服务端继续执行;订单号 PO1000000000000011,状态 pending。\"}]}"
A/B 截断后总的省略信息:没有;C 的 omitted 字段: 28
C 那种写法把省略写进了返回值:omitted 字段告诉模型少了 28 条。28 这个数字不是给日志看的,是给模型看的。少了多少、还剩多少,是它能判断"这份数据能不能支撑结论"的唯一依据。
定位:字节截断切在汉字上
B 那种按字节切的写法还有一个额外问题。汉字在 UTF-8 里占三个字节,截断位置落在字节中间时,解码会得到一个替换符 U+FFFD。换几个截断位置就能看到:
--- 换个截断位置,U+FFFD 会不会出现(按字节切,位置略有不同)---
截到 8 字节 -> "{\"note\":" 替换符: 0
截到 9 字节 -> "{\"note\":\"" 替换符: 0
截到 10 字节 -> "{\"note\":\"�" 替换符: 1
截到 11 字节 -> "{\"note\":\"�" 替换符: 1
截到 12 字节 -> "{\"note\":\"订" 替换符: 0
截到 13 字节 -> "{\"note\":\"订�" 替换符: 1
截到 14 字节 -> "{\"note\":\"订�" 替换符: 1
同一个字符串,截到 10 字节和截到 12 字节,一个是坏字符一个是完整的"订"。这类问题不会稳定复现,取决于截断位置和内容长度,只会偶尔出现,很难靠抽样发现。
想保留头尾也没用
按"头留一半、尾留一半"截,看起来能保留开头结构和结尾结构,实际上中间断开以后 JSON 依然拼不回原结构。
--- 想要「头尾都留」,切中间的写法 ---
D 保头 + 保留尾 1218 字符 JSON 可解析: 否 替换符 U+FFFD: 0 末尾 24 字: "00000039,状态 pending。\"}]}" (Bad control character in string literal in JSO)
注意 D 也一样解析不了:JSON 被从中间截断了,头尾拼不回原结构。
--- 换成「先按行切」的思路:JSON 没有行,但数组可以一行一条 ---
E 按行切数组元素 968 字符 JSON 可解析: 否 替换符 U+FFFD: 0 末尾 24 字: "工单:上游超时后重试了两次,日志里只留了一条\"," (Expected double-quoted property name in JSON a)
按行切听起来最安全,但这段 JSON 是 JSON.stringify 出来的单行文本,一个换行都没有,所谓按行切其实还是按字符切。
改法:先解析,再按结构裁
有效的做法是把"截断"换成"裁剪",顺序是先解析再裁:
function clipForContext(raw, maxChars) {
const parsed = JSON.parse(raw);
const keep = [];
let chars = 0;
for (const row of parsed.rows) {
const one = JSON.stringify(row);
if (chars + one.length > maxChars) break;
keep.push(row);
chars += one.length;
}
return JSON.stringify({
rows: keep,
total: parsed.rows.length,
omitted: parsed.rows.length - keep.length,
note: "rows 已按字符预算裁剪,未返回的条目在数据库里依然存在",
});
}
两个要点。裁的是数组元素,所以 JSON.stringify 出来的结果永远合法,模型能正常解析。total 和 omitted 一起给出,模型知道自己手里是全部还是抽样;只写 omitted 的话,它没法判断占比。实测里 C 的做法就是这样:1619 个字符,能解析,缺了 28 条明明白白写在字段里。
防复发:在工具层限体积
截断是最后一道补丁,不是第一道。工具本身就不该把 5000 字符原样返回,能让数据库做的事别搬到上下文里:查询带上 limit,只取需要的列,长文本字段先算长度再决定要不要带上全文。
返回值里给每条记录加一个体积上限,超出的部分落到一个 truncated: true 标记上,再配一个取完整内容的工具调用入口。这样模型遇到被裁的记录时有一个明确的下一步动作,而不是拿着半条记录继续推理。
判断依据
截断这件事的验收标准落在模型能不能分清完整和残缺上,字符数降下来只是副产品。按字符切和按字节切都省掉了拼接的麻烦,代价是把残破的数据伪装成完整的。现在的做法是把省略写进返回值本身,让上下文里留下的每一段数据都说得清自己是什么。
按结构裁这种做法我一开始也嫌麻烦,要动工具的返回结构;三种截法实测过之后,麻烦的那一种反而是唯一不留隐患的。按字节截这条只建议用在二进制和压缩场景,文本一旦可能含多字节字符,就别拿它当省事的手段。