侧边栏壁纸
博主头像
一笑痕

人生若只如初见,
是可喜亦或者是可悲?

  • 累计撰写 135 篇文章
  • 累计收到 7 条评论

裁历史别从中间切,工具调用和它的返回值要成对

2026-9-27 / 0 评论 / 7 阅读

裁历史别从中间切,工具调用和它的返回值要成对

历史逼近窗口上限,按「只留最近 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 条」的不一致,而问题只在其中一条路径上复现,日志里又看不出窗口大小,排查时间基本都花在找那条路径上。

    🤞 分享