ByteNoteByteNote
折叠长对话,别让摘要的摘要爆炸
字

字节笔记本

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

折叠长对话,别让摘要的摘要爆炸

API中转
¥120

Subagent:并行与任务拆分

本文是《从 0 写 Agent》系列第 17 章。这个系列基于开源项目 agents-go(OpenAI Agents SDK 的 Go 移植版),用 Go 从零实现一个生产级 Agent 运行时;本章讲「NestHandoffHistory:折叠长对话避免"summary 的 summary"」。

上一章我们讲了 InputFilter 可以清理 handoff 历史。但如果交接链很长,分诊→退款→主管→法务,每个 Agent 都做一次过滤,历史会怎样?

如果每次都"总结上一次的总结",你会得到"summary 的 summary 的 summary",信息层层丢失。agents-go 的 NestHandoffHistory 用一个巧妙设计解决这个问题:折叠前先展开上次的 summary,再做新 summary。 这一章拆这个"反嵌套"的机制。


引子:交接链的成本问题

想象一条交接链:

text
用户 → 分诊 Agent(调了 3 个工具,产生 8 条历史)
     → 退款 Agent(调了 2 个工具,产生 5 条历史)
     → 主管 Agent(要审批,看到前面所有历史)

主管 Agent 接管时,如果看到完整历史,是 13+ 条。如果退款 Agent handoff 时用了 summary,主管看到的是"分诊的 summary + 退款的 5 条"。

但如果再往下传,法务 Agent 接管,它看到的是"分诊 summary 的 summary + ..."。每多一层交接,summary 就嵌套一次,信息衰减。

这就是"summary 的 summary"问题,嵌套压缩导致信息层层丢失。


Agent 循环:一切的基础

NestHandoffHistory 的核心思路

NestHandoffHistory(handoff_history.go:47)的解法极其巧妙,折叠前先展开:

text
新 handoff 时:
  1. 拿到当前历史(可能含上次的 summary)
  2. flattenNestedHistory:把上次的 summary 还原回原始 transcript
  3. 用 mapper 把"还原后的完整 transcript"做成新 summary

关键,每次 summary 都基于"完整的原始历史",而不是"上次的 summary"。 这样不管交接链多长,每次的 summary 都是"完整历史的一次压缩",而不是"压缩的压缩"。


源码对照:折叠前先展开

NestHandoffHistory 返回的是一个 InputFilter(handoff_history.go:47):

go
// agents/handoff_history.go:47(骨架)
func NestHandoffHistory(opts NestHistoryOptions) func(HandoffInputData) HandoffInputData {
    start, end := markers(opts)  // <CONVERSATION HISTORY> 标记
    mapper := opts.Mapper
    if mapper == nil {
        mapper = DefaultHandoffHistoryMapper  // 默认 mapper
    }
    return func(data HandoffInputData) HandoffInputData {
        // 关键第一步:展开任何已有的嵌套 summary
        transcript := flattenNestedHistory(data.InputHistory, start, end)
        // 第二步:用 mapper 把完整 transcript 做成新 summary
        return HandoffInputData{InputHistory: mapper(transcript)}
    }
}

两步,先 flatten(展开),再 map(总结)。 顺序至关重要。

flattenNestedHistory,还原嵌套的 summary

flattenNestedHistory(handoff_history.go:100)做的事是,扫描历史,如果发现某个 message 是上次 handoff 的 summary(被 start/end 标记包裹),就把它还原成原始的 transcript items。

怎么识别 summary?靠标记,上次的 summary 用 <CONVERSATION HISTORY> 和 </CONVERSATION HISTORY> 包裹(handoff_history.go:11)。flatten 时找到这个标记,把里面的内容解析回原始 items。

text
当前历史:
  [user: 我要退款]
  [assistant: <CONVERSATION HISTORY>
   1. {...原始 item 1...}
   2. {...原始 item 2...}
   </CONVERSATION HISTORY>]
  [user: 继续退款流程]

flatten 后(summary 被还原):
  [user: 我要退款]
  {...原始 item 1...}      ← 还原
  {...原始 item 2...}      ← 还原
  [user: 继续退款流程]

这样不管嵌套多少层,flatten 都能一层层展开,最终得到完整的扁平 transcript。


源码对照:summary 的序列化协议

summaryMessage(handoff_history.go:77)定义了 summary 的格式,每个 transcript item 序列化成一行 JSON,带序号,用标记包裹:

go
// agents/handoff_history.go:77(骨架)
func summaryMessage(transcript []TResponseInputItem, start, end string) TResponseInputItem {
    lines := []string{
        "For context, here is the conversation so far between the user and the previous agent:",
        start,  // <CONVERSATION HISTORY>
    }
    for i, item := range transcript {
        data, _ := MarshalInputItem(item)  // 序列化成 JSON
        lines = append(lines, strconv.Itoa(i+1)+". "+string(data))  // "1. {...}"
    }
    lines = append(lines, end)  // </CONVERSATION HISTORY>
    return responses.ResponseInputItemParamOfMessage(
        strings.Join(lines, "\n"), EasyInputMessageRoleAssistant,
    )
}

一个 summary message 长这样(模型看到的):

text
For context, here is the conversation so far between the user and the previous agent:
<CONVERSATION HISTORY>
1. {"type":"message","role":"user","content":"我要退款"}
2. {"type":"function_call","name":"get_order","arguments":"{...}"}
3. {"type":"function_call_output","output":"{...}"}
4. {"type":"message","role":"assistant","content":"好的,查到订单了"}
</CONVERSATION HISTORY>

为什么用"一行一个 JSON"而不是别的格式? 因为要 round-trip,summary 里的每个 item 必须能精确还原成原始的 TResponseInputItem。MarshalInputItem/UnmarshalInputItem 保证序列化和反序列化完全可逆。

为什么带序号(1. 2. )? 主要是可读性,模型读 summary 时,有序号更容易理解顺序。flatten 时用 stripLineNumber 去掉序号再反序列化。


设计权衡:标记的稳定性

注意 start/end 标记(<CONVERSATION HISTORY>)是个协议契约,summary 的格式必须严格遵守,否则 flatten 解析不出来。

NestHistoryOptions 允许自定义标记(handoff_history.go:31):

go
type NestHistoryOptions struct {
    Mapper      HandoffHistoryMapper
    StartMarker string  // 默认 <CONVERSATION HISTORY>
    EndMarker   string  // 默认 </CONVERSATION HISTORY>
}

但注释警告(handoff_history.go:30):如果你自定义 Mapper,想用 flatten 功能,必须在输出里用相同的 start/end 标记包裹。否则 flatten 认不出你的 summary,会当普通 message 处理,嵌套问题又回来了。

这是"格式约定"的代价,约定一旦定下,所有参与者都要遵守。 默认标记对绝大多数场景够用,自定义要谨慎。


设计权衡:默认 Mapper vs LLM Mapper

HandoffHistoryMapper(handoff_history.go:19)是"怎么把 transcript 变成 summary"的策略:

go
type HandoffHistoryMapper func(transcript []TResponseInputItem) []TResponseInputItem

默认 Mapper(DefaultHandoffHistoryMapper,handoff_history.go:71),不做真正的总结,只是把 transcript 原样序列化进一个 message。等于"打包"而不是"压缩"。

为什么默认不总结?因为总结要调 LLM,而 InputFilter 在 handoff 时同步执行,调 LLM 会阻塞交接,增加延迟。默认用"打包"(确定性、零延迟),需要真总结时用户自定义 Mapper:

go
customMapper := func(transcript []agents.TResponseInputItem) []agents.TResponseInputItem {
    // 调 LLM 总结(异步或接受延迟)
    summary := callLLMSummarize(transcript)
    return []agents.TResponseInputItem{summaryMessage(summary, start, end)}
}

filter := agents.NestHandoffHistory(agents.NestHistoryOptions{
    Mapper: customMapper,
})

这是"默认零延迟,高级可定制"的设计,90% 场景用打包就够(保留全部信息,只是换了格式);10% 需要真总结的场景,自定义 Mapper 调 LLM。


为什么这个设计叫"Nest"

"Nest"(嵌套)这个名字来自它的核心特性,支持交接链的嵌套,但通过"先展平再总结"避免真正的嵌套压缩。

对比两种做法:

朴素做法(会嵌套):

text
分诊 → 退款:summary1 = compress(分诊历史)
退款 → 主管:summary2 = compress(summary1 + 退款历史)  ← summary 套 summary
主管 → 法务:summary3 = compress(summary2 + 主管历史)  ← 三层套娃

信息层层丢失。

NestHandoffHistory(反嵌套):

text
分诊 → 退款:summary1 = compress(flatten(分诊历史))
退款 → 主管:先 flatten(summary1) 还原成完整 transcript,再 compress
主管 → 法务:先 flatten 还原完整 transcript,再 compress

每次都基于完整历史压缩,不管链多长,信息不衰减。


小结

读完这一章,你应该理解:

  1. "summary 的 summary"问题,交接链每层都压缩,信息层层丢失
  2. NestHandoffHistory 的解法,折叠前先展开,每次基于完整 transcript 压缩
  3. 序列化协议,一行一个 JSON,带序号,标记包裹,保证 round-trip
  4. 标记是契约,自定义 Mapper 必须用相同标记,否则 flatten 失效
  5. 默认打包不总结,零延迟;真总结要自定义 Mapper 调 LLM

核心洞察,"反嵌套"的关键是 flatten。 不管嵌套多少层,flatten 都能展开成扁平 transcript,再做一次干净的 summary。这让交接链的成本可控,每次 summary 都是 O(完整历史) 而不是 O(链长 × 历史)。


下一篇

第 18 章,Agent as Tool:嵌套调用。除了 Handoff(切权),还有一种多 Agent 模式,Agent 当工具用。主 Agent 调用子 Agent,子 Agent 跑完返回结果,控制权归还。这是和 Handoff 完全不同的拓扑。


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

相关文章

分享: