ByteNoteByteNote
交接之后历史怎么算:双列表与 InputFilter
字

字节笔记本

2026年10月24日 · 约 15 分钟读完

交接之后历史怎么算:双列表与 InputFilter

API中转
¥120

Subagent:并行与任务拆分

本文是《从 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 接待用户,过程中调了几个工具(查用户画像、查订单),然后交接给退款专家。退款专家接管时,看到的历史是:

text
[user: 我要退款]
[assistant(分诊): 我帮您查一下]
[tool_call: get_user_profile]
[tool_output: {...用户信息...}]
[tool_call: get_order]
[tool_output: {...订单信息...}]
[assistant(分诊): 好的,转给退款专家]
[handoff: transfer_to_refund]

退款专家需要看这些吗? 部分需要(用户信息和订单有用),但分诊 Agent 的中间推理、内部工具调用的细节,可能干扰退款专家的判断,它只需要"用户要退订单 X"这个结论,不需要知道分诊是怎么查出来的。

所以需要 InputFilter,改写目标 Agent 看到的历史。 但改写带来一个新问题……


Agent 循环:一切的基础

难题:过滤后的历史 vs Session 该存什么

如果 InputFilter 把历史改成"精简版",那 Session 存什么?

选择后果
存过滤后的原 Agent 的工具调用细节永久丢失,Session 不完整
存完整的对,但目标 Agent 看到的是过滤版,Session 存的是完整版,两者不一致

正确答案,存完整的,但目标 Agent 看到过滤的。 这就要求"模型视角的历史"和"持久化的历史"分离。


源码对照:双 item 列表

agents-go 的解法是 runner 维护两个 item 列表(run.go 的 runner 结构):

go
// runner 的核心状态(run.go:176,简化)
type runner struct {
    // ...
    generatedItems []RunItem   // 模型视角的累积(会被 handoff filter 重写)
    sessionItems   []RunItem   // 持久化视角的累积(永不重置)
}

generatedItems,模型看到的

这个列表是"模型当前轮次看到的历史"。正常情况下往里 append。但 handoff 时,InputFilter 会重写它:

go
// agents/run.go:440(handoff 分支)
if filter := r.handoffInputFilter(step.Handoff); filter != nil {
    filtered, _ := applyHandoffInputFilter(filter, originalInput, generatedItems)
    originalInput = filtered      // 改写模型视角的原始输入
    generatedItems = nil          // 清空!过滤后的历史已经并入 originalInput
}

关键两步,

  1. applyHandoffInputFilter 把完整历史交给 filter 函数,拿回过滤后的版本
  2. generatedItems = nil,清空。因为过滤后的内容已经合并进 originalInput 了,模型下一轮看到的是 originalInput(已含过滤历史)

这让目标 Agent 看到的是"干净的过滤历史",不含原 Agent 的中间细节。

sessionItems,持久化用的

这个列表是"Session 要存的完整历史"。它永不重置,不管 handoff 怎么过滤:

go
// 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):

go
// agents/handoff.go:44
InputFilter func(HandoffInputData) HandoffInputData

// agents/handoff.go:13
type HandoffInputData struct {
    InputHistory []TResponseInputItem  // 完整历史
}

它接收完整历史,返回过滤后的历史。 用法:

go
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):

go
// 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 的完整历史处理:

text
分诊 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 历史处理的三个核心设计:

  1. 双 item 列表,generatedItems(模型视角,可被 filter 重写)vs sessionItems(持久化视角,永不重置)
  2. InputFilter,改写模型看到的历史,但不影响 Session 存的完整历史
  3. 重置 previousResponseID,过滤后本地和服务端历史不一致,必须重置,放弃省 token

核心洞察,"模型视角"和"持久化视角"必须分离。 模型要干净聚焦,持久化要完整可审计。双列表让两者各取所需,是 handoff 历史处理的精髓。


下一篇

第 17 章,NestHandoffHistory:折叠长对话。当交接链一长(分诊→退款→主管→...),每个 Agent 都收到完整历史会很贵。NestHandoffHistory 把扁平历史折叠成一条 summary,还巧妙避免了"summary 的 summary"嵌套问题。


本文是《从 0 写 Agent》系列第 16 章,案例代码基于开源项目 agents-go(OpenAI Agents SDK 的 Go 移植版)。全系列目录见总览。

相关文章

分享: