
历史逼近窗口上限,按「只留最近 N 条」把前面那段裁掉,多数时候能继续跑,偶尔整个请求被直接拒掉。看着不像长度问题,因为更短的那一次反而报了错。
为什么裁完还能跑,某一次就报错
一次工具调用在历史里占两条消息。assistant 那条带 tool_calls,里面每个调用有自己的 id;紧跟着一条 role 是 tool,带 tool_call_id 指回前面那个 id。配对靠 id,不靠顺序之外的东西。
裁剪于是不是「删几条消息」这么简单,它可能在中间把一对拆开。校验规则也很直接:任何一条 tool 消息,都必须能在它前面找到声明它的那条 assistant 消息。
从中间切会看到什么
构造一段 15 条的历史,工具调用全部成对,整段校验是干净的。然后按不同窗口大小只留最后 N 条,逐档校验。
按「只留最后 N 条」裁,看从哪一档开始配对断掉:
窗口 第一条的角色 校验结果
--------------------------------------------------------------------------
2 user 配对完整
3 tool 位置 0:tool 消息 call_5 找不到前面声明它的 assistant 消息
4 assistant+1个tool_calls 配对完整
5 user 配对完整
6 assistant 配对完整
7 tool 位置 0:tool 消息 call_4 找不到前面声明它的 assistant 消息
8 assistant+1个tool_calls 配对完整
9 user 配对完整
10 assistant 配对完整
11 tool 位置 0:tool 消息 call_3 找不到前面声明它的 assistant 消息
12 tool 位置 0:tool 消息 call_2 找不到前面声明它的 assistant 消息
13 tool 位置 0:tool 消息 call_1 找不到前面声明它的 assistant 消息
14 assistant+3个tool_calls 配对完整
15 user 配对完整
窗口大小和结果没有单调关系:留 11 条断,留 10 条不断,留 7 条断,留 4 条又不断。断的那几档,首条角色都是 tool,它带的 id 指向那条已经被切掉的 assistant。
只留最后几条真的安全吗
把每档留下的第一条是什么摆出来,规律更清楚。
按「条数」裁之外,还要注意切点落在 tool 上时:
留最后 4 条 -> A+TUA
留最后 5 条 -> UA+TUA
留最后 6 条 -> AUA+TUA
留最后 7 条 -> TAUA+TUA
留最后 8 条 -> A+TAUA+TUA
(U=user A=assistant A+=assistant 带 tool_calls T=tool 结果)
从 U 开始是安全的,从 A 开始也安全,从 A+ 开始要看它声明的调用有没有全部跟在后面,从 T 开头一定断。危险的不是留了多少条,是切点落在哪一类消息上。
安全裁剪怎么写
function isToolish(m) {
return m.role === "tool" || (m.role === "assistant" && Array.isArray(m.tool_calls));
}
function trimSafe(messages, budget) {
let start = Math.max(0, messages.length - budget);
// 往后挪,直到切点落在一条不依赖前文的消息上
while (start < messages.length && isToolish(messages[start])) start++;
return messages.slice(start);
}
这个写法只保证不把一对拆开,不保证留下的条数等于预算,预算不够时它宁可少留。
安全裁剪,预算 4 条 -> 真正留下 2 条,首条角色 user,校验 配对完整
安全裁剪,预算 6 条 -> 真正留下 6 条,首条角色 assistant,校验 配对完整
安全裁剪,预算 8 条 -> 真正留下 6 条,首条角色 assistant,校验 配对完整
安全裁剪,预算 10 条 -> 真正留下 10 条,首条角色 assistant,校验 配对完整
预算 8 条那一档真正留下 6 条:往前挪的时候跳过了一条带 tool_calls 的 assistant,连带它后面那条返回值一起排除了。真实系统里预算按字符或者 token 算,这里用条数演示,道理一样,切点必须落在轮次的边界上。
裁掉的中间那段怎么补
两种做法。给被裁掉的部分留一句摘要,作为一条 user 消息放在最前面,模型读到摘要就知道前面发生过什么;或者按轮裁,从最近一条 user 消息往回收,收到预算为止,这样天然不会切在中间,因为 user 消息不带 tool_call_id。
还有一种情况要单独查:裁完之后首条是带 tool_calls 的 assistant,但它声明的调用没跟回来。这种比 tool 打头更隐蔽,校验函数两个方向都要走一遍,一个查「有没有孤儿返回值」,一个查「有没有没回填的调用声明」。
这一条为什么值得单独防
模型那一侧的行为没实测(本机没有可用的模型接口),能确定的只是请求能不能被接受。配对是协议层面的硬要求,构造出来的消息只要断在中间,服务端在解析阶段就该拒绝,跟模型能力无关。所以这类失败可以用纯函数测出来,不必真跑一轮对话。
我把裁剪写成纯函数,只在拼请求之前调用一次。分散在几个分支里各裁一遍的写法,很快就会出现「这次窗口 12 条、那次 8 条」的不一致,而问题只在其中一条路径上复现,日志里又看不出窗口大小,排查时间基本都花在找那条路径上。