
字节笔记本
2026年10月24日 · 约 15 分钟读完
交接之后历史怎么算:双列表与 InputFilter

本文是《从 0 写 Agent》系列第 16 章。这个系列基于开源项目 agents-go(OpenAI Agents SDK 的 Go 移植版),用 Go 从零实现一个生产级 Agent 运行时;本章讲「Handoff 的历史处理:双 item 列表与 InputFilter」。
Handoff 切换 Agent 时,有个微妙问题,目标 Agent 该看到什么对话历史?
全传?那它可能看到原 Agent 的一堆内部工具调用,干扰判断。过滤?那过滤掉的内容,Session 要不要存?存哪份?agents-go 用一个精妙的设计解决,双 item 列表(generatedItems vs sessionItems)+ InputFilter。这一章拆解它。
引子:历史该不该全传
分诊 Agent 接待用户,过程中调了几个工具(查用户画像、查订单),然后交接给退款专家。退款专家接管时,看到的历史是:
[user: 我要退款]
[assistant(分诊): 我帮您查一下]
[tool_call: get_user_profile]
[tool_output: {...用户信息...}]
[tool_call: get_order]
[tool_output: {...订单信息...}]
[assistant(分诊): 好的,转给退款专家]
[handoff: transfer_to_refund]退款专家需要看这些吗? 部分需要(用户信息和订单有用),但分诊 Agent 的中间推理、内部工具调用的细节,可能干扰退款专家的判断,它只需要"用户要退订单 X"这个结论,不需要知道分诊是怎么查出来的。
所以需要 InputFilter,改写目标 Agent 看到的历史。 但改写带来一个新问题……

难题:过滤后的历史 vs Session 该存什么
如果 InputFilter 把历史改成"精简版",那 Session 存什么?
| 选择 | 后果 |
|---|---|
| 存过滤后的 | 原 Agent 的工具调用细节永久丢失,Session 不完整 |
| 存完整的 | 对,但目标 Agent 看到的是过滤版,Session 存的是完整版,两者不一致 |
正确答案,存完整的,但目标 Agent 看到过滤的。 这就要求"模型视角的历史"和"持久化的历史"分离。
源码对照:双 item 列表
agents-go 的解法是 runner 维护两个 item 列表(run.go 的 runner 结构):
// runner 的核心状态(run.go:176,简化)
type runner struct {
// ...
generatedItems []RunItem // 模型视角的累积(会被 handoff filter 重写)
sessionItems []RunItem // 持久化视角的累积(永不重置)
}generatedItems,模型看到的
这个列表是"模型当前轮次看到的历史"。正常情况下往里 append。但 handoff 时,InputFilter 会重写它:
// agents/run.go:440(handoff 分支)
if filter := r.handoffInputFilter(step.Handoff); filter != nil {
filtered, _ := applyHandoffInputFilter(filter, originalInput, generatedItems)
originalInput = filtered // 改写模型视角的原始输入
generatedItems = nil // 清空!过滤后的历史已经并入 originalInput
}关键两步,
applyHandoffInputFilter把完整历史交给 filter 函数,拿回过滤后的版本generatedItems = nil,清空。因为过滤后的内容已经合并进originalInput了,模型下一轮看到的是originalInput(已含过滤历史)
这让目标 Agent 看到的是"干净的过滤历史",不含原 Agent 的中间细节。
sessionItems,持久化用的
这个列表是"Session 要存的完整历史"。它永不重置,不管 handoff 怎么过滤:
// run.go 里,每次产生新 item 都追加到两个列表
generatedItems = append(generatedItems, step.NewStepItems...)
r.sessionItems = append(r.sessionItems, step.NewStepItems...) // 这个永不重置sessionItems 累积了所有 Agent(分诊 + 退款 + ...)产生的全部 item。 Run 结束存 Session 时,存的是这个完整列表(run.go:504-526 注释明确说明)。
为什么要两个
因为"模型视角"和"持久化视角"需求不同:
- 模型要"干净、聚焦",太多无关历史让它混乱
- 持久化要"完整、可审计",丢失细节就无法复盘
双列表让两者各取所需,handoff filter 只影响模型视角,不破坏持久化完整性。 这是个极其优雅的解法。
源码对照:InputFilter
InputFilter 是 Handoff 的可选字段(handoff.go:44):
// agents/handoff.go:44
InputFilter func(HandoffInputData) HandoffInputData
// agents/handoff.go:13
type HandoffInputData struct {
InputHistory []TResponseInputItem // 完整历史
}它接收完整历史,返回过滤后的历史。 用法:
handoff := agents.Handoff{
ToolName: "transfer_to_refund",
InputFilter: func(data agents.HandoffInputData) agents.HandoffInputData {
// 只保留消息,丢弃工具调用细节
var filtered []agents.TResponseInputItem
for _, item := range data.InputHistory {
if isMessage(item) { // 只留 message,不留 tool_call/tool_output
filtered = append(filtered, item)
}
}
return agents.HandoffInputData{InputHistory: filtered}
},
}常见的过滤策略:
- 只留消息,删工具调用(最常见的"精简")
- 把多条历史总结成一条 summary(第 17 章的 NestHandoffHistory)
- 保留最近 N 条,删更早的
- 根据目标 Agent 的需要,定制保留哪些
设计权衡:为什么 handoff 后要重置 previousResponseID
注意 handoff 分支里这行(run.go:449):
// agents/run.go:449
previousResponseID = ""handoff 后,previousResponseID 被清空。 为什么?
回忆第 11 章,previousResponseID 让服务端接续历史,省 token。但它的前提是,服务端存的历史和本地历史一致。
handoff 用 InputFilter 过滤后,本地历史变了(删了工具调用)。但服务端存的还是完整的历史,两者不一致了。如果还用 previousResponseID,服务端会接上完整历史,导致模型看到"我以为过滤掉的细节又回来了",矛盾。
所以必须重置 previousResponseID = "",下一轮改为全量发送过滤后的历史。serverItemCount 也重置为 0(run.go:450),让服务端状态重新计数。
注释明说(run.go:447):"The server's stored history is unfiltered, so we can no longer chain via previous_response_id; resend the filtered input",服务端存的是未过滤的,不能再链式接续,重新发过滤后的输入。
这是服务端状态(省 token)和 InputFilter(清理历史)的天然冲突,一旦过滤,省 token 的福利就没了。 工程上接受这个代价,安全/聚焦比省 token 重要。
设计权衡:为什么过滤而不总结
InputFilter 默认是"过滤"(删掉部分),但也可以做"总结"(用模型把历史压缩成 summary)。agents-go 提供了 NestHandoffHistory(第 17 章)做总结。
两种策略对比:
| 策略 | 怎么做 | 优点 | 缺点 |
|---|---|---|---|
| 过滤 | 删掉部分 item | 快、确定性、不耗 token | 可能删掉有用的 |
| 总结 | 用模型把历史压成一条 summary | 信息保留好 | 慢、耗 token、可能丢细节 |
agents-go 默认用过滤(如果配了 InputFilter),因为过滤是确定的、不耗额外 token。总结是高级选项,适合"历史特别长、必须压缩"的场景。
没配 InputFilter 时怎么办
如果 Handoff 没配 InputFilter,目标 Agent 看到完整历史,不做任何改写。这适合"目标 Agent 需要完整上下文"的场景(比如主管 Agent 接管时要看全部细节)。
所以 InputFilter 是可选的,你要精简就配,要完整就不配。 agents-go 不强制,给你选择权。
完整的 handoff 历史处理流程
把第 15-16 章串起来,一次 handoff 的完整历史处理:
分诊 Agent 跑了几轮,generatedItems 和 sessionItems 都累积了历史
↓
模型调用 transfer_to_refund(handoff)
↓
executeHandoff 执行:
- OnInvoke 返回 refundAgent
- OnHandoff 做副作用
↓
回到 loop 的 stepHandoff 分支:
- callHandoff hook 触发
- 如果有 InputFilter:
- applyHandoffInputFilter 过滤历史
- originalInput = 过滤后的历史
- generatedItems = nil(清空模型视角)
- previousResponseID = ""(重置服务端状态)
- serverItemCount = 0
- sessionItems 不动(保留完整历史)
↓
currentAgent = refundAgent
shouldRunStartHooks = true(下一轮触发新 Agent 的 start hook)
↓
continue,下一轮用 refundAgent 的配置 + 过滤后的历史五个关键动作,过滤、清空 generatedItems、重置 previousResponseID、保留 sessionItems、切换 agent。每一个都有理由。
小结
读完这一章,你应该理解 handoff 历史处理的三个核心设计:
- 双 item 列表,generatedItems(模型视角,可被 filter 重写)vs sessionItems(持久化视角,永不重置)
- InputFilter,改写模型看到的历史,但不影响 Session 存的完整历史
- 重置 previousResponseID,过滤后本地和服务端历史不一致,必须重置,放弃省 token
核心洞察,"模型视角"和"持久化视角"必须分离。 模型要干净聚焦,持久化要完整可审计。双列表让两者各取所需,是 handoff 历史处理的精髓。
下一篇
第 17 章,NestHandoffHistory:折叠长对话。当交接链一长(分诊→退款→主管→...),每个 Agent 都收到完整历史会很贵。NestHandoffHistory 把扁平历史折叠成一条 summary,还巧妙避免了"summary 的 summary"嵌套问题。
本文是《从 0 写 Agent》系列第 16 章,案例代码基于开源项目 agents-go(OpenAI Agents SDK 的 Go 移植版)。全系列目录见总览。
