手写 Agent ⑤,上下文管理

针对大文件输出挤爆上下文的问题,加入工具输出截断、token 预算、自动压缩和最大迭代次数等防线。

outlier 9 分钟 3567 字 0 0
系列教程Agent从入门到放弃 第 28 / 32 章
  1. 01Agent 从入门到放弃:教程大纲
  2. 02大模型、Chatbot、Agent,一次把名字理清楚
  3. 03Agent 的最小定义,一个 while 循环
  4. 04Agent 简史,从 ReAct 到 Claude Code
  5. 05Workflow 还是 Agent?大部分场景你不需要 Agent
  6. 06Agent 能力地图,为什么编码 Agent 最先成熟
  7. 07Token,模型眼里的世界
  8. 08Next-token prediction,一个字一个字地猜
  9. 09采样,temperature、top-p 与"同题不同答"
  10. 10Transformer 与 Attention,不推公式的直觉版
  11. 11Context Window,模型的内存条
  12. 12训练三阶段,能力是怎么被"装"进去的
  13. 13推理模型,让模型先想再答
  14. 14用原理解释怪行为,原理篇结业考
  15. 15从 Prompt 工程到 Context 工程
  16. 16System Prompt 写作,角色、约束与示例
  17. 17Context Rot,为什么长对话越聊越笨
  18. 18项目记忆,CLAUDE.md 与 AGENTS.md
  19. 19检索,RAG 还是 Agentic Search
  20. 20Function Calling 协议拆解
  21. 21工具设计,写给模型用的 API
  22. 22MCP 入门,工具界的 USB-C
  23. 23用 Go 手写一个 MCP Server
  24. 24MCP 进阶与边界
  25. 25手写 Agent ②,第一个工具,read_file 与 list_files
  26. 26手写 Agent ③,让它会改代码,edit_file
  27. 27手写 Agent ④,给它一个终端,run_bash 与权限
  28. 28手写 Agent ⑤,上下文管理
  29. 29手写 Agent ⑥,流式输出、错误处理与重试
  30. 30手写 Agent ⑧,Skills 与 MCP 接入
  31. 31Claude Code 解剖,对照自己的实现
  32. 32自动化,Hooks、自定义命令与 Headless 模式
查看教程目录

开场:一个日志文件,让它彻底失忆

那天线上出了个问题,我把服务的日志拉下来,丢进一个目录,对 mini-agent 说:"看看 app.log,找找有没有报错。"

它很听话:

⏺ run_bash {"command":"cat app.log"}

日志文件有 15MB。那时候的 mini-agent 还没有任何截断,三十万行日志原封不动地塞进了 tool_result。下一次请求,API 返回了一个错误:上下文超长。

我想,没关系,重新问一次。还是错误。换个问法,还是错误。输入 /compact 想压缩一下,压缩请求本身也报错了。

后来我才想明白:那三十万行日志已经在消息历史里了。 之后的每一次请求都要带着它,包括压缩请求,所以每一次都会超长。唯一的办法是 /reset,把整个对话清空,前面二十分钟的工作全部作废。

这一篇讲上下文管理。第三部分讲了很多原则:注意力是有限的预算(第 14 篇)、长对话越聊越笨(第 16 篇),这一篇把它们落到代码里。读完本篇,你应该能回答这几个问题:

  • Agent 的上下文是从哪几个口子涨起来的?
  • 为什么截断工具输出是第一道、也是最重要的防线?截断时要保留哪部分?
  • 怎么知道当前的上下文有多大?什么时候该压缩?
  • 自动压缩是怎么实现的?会丢什么?
  • 为什么还需要一个最大迭代次数?

一、上下文是怎么涨起来的

1.1 三个口子

第 14 篇拆过一次请求,上下文最大的两块是检索内容和工具输出。放到 Agent 的循环里看,上下文的增长有三个来源:

flowchart LR T["工具输出
一次命令可能几万行"] --> C["上下文"] F["读进来的文件
一个文件几千行"] --> C H["对话历史
每一圈 +2 条,只增不减"] --> C C --> X["越来越贵、越来越慢、
越来越笨,最后超长"] style T fill:#ffcdd2,stroke:#b71c1c style X fill:#ffcdd2,stroke:#b71c1c

前两个是"突发"的:一次调用就可能进来一大块。第三个是"慢性"的:每一圈都在涨,几十圈之后积少成多。

1.2 一旦进去,就出不来了

开场的故事里最致命的一点是:东西一旦进了消息历史,之后的每一圈都要带着它。 第 02 篇说过,状态全在消息历史里,而消息历史只增不减。一次 cat 大文件,不只是这一次请求很贵,之后的每一次请求都很贵;如果它大到超过了上下文窗口,之后的每一次请求都会失败。

所以上下文管理分成几道防线,越靠前越重要:

flowchart TB L1["① 截断工具输出
在入口处拦住"] --> L2["② token 预算
知道现在有多大"] L2 --> L3["③ 自动压缩
太大了就总结成摘要"] L3 --> L4["④ 最大迭代次数
转太多圈就停"] style L1 fill:#e8f5e9,stroke:#2e7d32

二、第一道防线:截断工具输出

2.1 统一截断

第 25 篇的注册表里,所有工具的输出在返回之前都会经过一行:

out = Truncate(out, r.MaxOutput)

MaxOutput 默认是 4 万个字符:

// DefaultMaxOutput 是单次工具结果的字符上限,约合一两万 token。
const DefaultMaxOutput = 40000

在注册表里统一截断,而不是让每个工具自己截断,好处是不会有漏网之鱼:内置的工具、将来加的工具、MCP server 的工具,全部经过这一道关。第 23 篇说过,你没法控制第三方 MCP server 返回多大的结果。

2.2 保留头和尾

截断的方式很有讲究。agent/tools/registry.go:

// Truncate 把过长的输出截成"头 + 提示 + 尾"。
//
// 为什么保留尾巴:命令输出里最重要的往往在最后(报错、测试总结、退出码)。
func Truncate(s string, max int) string {
    r := []rune(s)
    if max <= 0 || len(r) <= max {
        return s
    }
    head, tail := max*2/5, max*2/5
    return string(r[:head]) +
        fmt.Sprintf("\n\n……(输出共 %d 字符,超过上限,中间 %d 字符已省略。需要细节时请缩小范围:用 grep 过滤、用 head/tail、或者 read_file 指定 offset 和 limit)……\n\n", len(r), len(r)-head-tail) +
        string(r[len(r)-tail:])
}

保留开头的 40% 和结尾的 40%,中间省略。 为什么不只保留开头?

想想命令的输出长什么样:

  • go test 的输出,开头是一堆 === RUN,最后才是 FAIL 的汇总和退出码;
  • 编译报错,往往最后几行才是真正的错误原因;
  • 一个程序崩溃时打印的日志,最后几行是 panic 信息和调用栈。

只保留开头,最重要的信息恰恰被截掉了。保留头尾,模型既能看到输出是怎么开始的,也能看到它是怎么结束的。

2.3 告诉模型被截断了,以及怎么办

中间那段提示,同样遵循第 20 篇的原则:错误信息(这里是截断信息)要告诉模型下一步怎么做:

……(输出共 15328904 字符,超过上限,中间 15288904 字符已省略。需要细节时请缩小范围:用 grep 过滤、用 head/tail、或者 read_file 指定 offset 和 limit)……

模型看到这一句,就知道:它看到的不是全部;如果需要看中间的部分,应该用 grep ERROR app.log 去过滤,而不是再 cat 一次。开场那个场景,有了截断之后,模型通常会在下一步改用 grep。

2.4 4 万个字符是多少 token

DefaultMaxOutput 的注释写的是"约合一两万 token"。这个换算是按代码和英文日志估的:第 06 篇说过,英文和代码大约三四个字符一个 token,4 万个字符就是一万出头。

但如果工具输出的是中文,比如一份中文的日志、一个中文的文档,汉字差不多一个字一个 token,4 万个字符就可能接近 4 万 token。同一个字符上限,在不同的内容上,对应的 token 数可能差三四倍。

为什么不直接按 token 截断?因为要知道一段文本有多少 token,得用模型的分词器去数,而 DeepSeek 没有单独的计数接口(第 06 篇),每数一次都要发一次请求。为了截断一段输出去调一次 API,得不偿失。按字符截断是一个便宜的近似:它保证了"单次输出不会大得离谱",这就够了。真正精确的记账,交给下一节的 token 预算。

另外留意一下实际的保留量:head 和 tail 各是上限的 40%,所以一段被截断的输出,实际留下来的是 3.2 万个字符,再加上中间那行提示。剩下的 20% 是有意留出的余量,保证截断后的结果一定比上限小。

2.5 每个工具自己也有分寸

统一截断是兜底,各个工具自己也有控制大小的办法(第 25 篇讲过):

  • read_file 默认最多读 2000 行,每行最多 2000 字符,并提示怎么用 offset 继续读;
  • list_files 最多列 500 项,跳过 node_modules 这类目录;
  • run_bash 的描述里提醒模型不要用它来读文件。

最好的截断,是一开始就不要读那么多。

三、第二道防线:token 预算

3.1 怎么知道现在有多大

要控制上下文,首先得知道它现在有多大。最直接的办法是:看上一次请求的用量。

每一次模型调用,响应里都有 usage:这次输入了多少 token、输出了多少 token。"上一次的输入 + 上一次的输出",差不多就是"下一次请求的上下文大小"(下一次还会多一点新的工具结果)。

// contextTokens 估算当前上下文的大小:上一次请求的全部输入 + 模型刚生成的输出。
func contextTokens(u llm.Usage) int {
    return u.TotalInput() + u.OutputTokens
}

注意这里用的是 TotalInput(),而不是 InputTokens。第 10 篇的坑 5:开了缓存之后,大部分输入会记在"缓存命中"那个字段里,只看 InputTokens 会严重低估。我第一版就是这么写错的,上下文都快满了,它还以为只有几千 token。

这个数字是模型的分词器算出来的,精确,而且不花钱,因为它是每次调用顺带返回的。比自己按字数估算准得多(第 06 篇的坑 1)。

Send 的循环里,每次调用完模型就记下它:

a.lastContext = contextTokens(resp.Usage)

在 REPL 里输入 /usage,就能看到当前的上下文大小和预算:

消息 23 条,当前上下文约 48213 token(预算 150000);累计输入 612094 token,输出 8302 token,约 $0.1937

3.2 超过预算就压缩

Agent 有一个 ContextBudget 字段,默认 15 万 token:

return &Agent{Model: model, System: system, Tools: reg, MaxTokens: 16000, MaxSteps: 50, UI: ui,
    ContextBudget: 150_000}

每一次调用模型之前,先检查一下:

// maybeCompact 在上下文超过预算时自动压缩。
func (a *Agent) maybeCompact(ctx context.Context) error {
    if a.ContextBudget <= 0 || a.lastContext < a.ContextBudget {
        return nil
    }
    return a.Compact(ctx)
}

为什么预算是 15 万,而 DeepSeek 的上下文窗口是 100 万?因为上下文窗口是"能装多少",预算是"装多少还能保持好用"。第 10 篇、第 16 篇说过,上下文越长,模型越慢、越贵、越笨。远在撑爆窗口之前,它的表现就已经开始变差了。15 万是一个保守的数字,你可以用 -budget 参数调整:

go run ./cmd/mini-agent -budget 80000     # 更早压缩
go run ./cmd/mini-agent -budget 0         # 不压缩

3.3 预算是"事后"的

这个记账方式有一个细节值得注意:lastContext 记的是上一次请求的大小,maybeCompact 在下一次请求之前检查它。这中间,工具刚刚执行完,结果已经追加进了历史,但还没有被计入。

大部分时候这没关系:一次工具结果最多几万字符(截断保证了这一点),预算离窗口上限还有很大的余量。但它说明了一件事:预算管不住"一次进来一大块"的情况。 假设没有截断,上一次请求是 10 万 token,没到 15 万的预算,不压缩;这一圈工具返回了 200 万 token 的日志,下一次请求直接超过窗口,失败。预算检查根本来不及介入。

这就是为什么截断必须排在第一道。预算管的是慢性的增长,截断管的是突发的冲击,两者缺一不可。

四、第三道防线:自动压缩

4.1 让模型写一份交接摘要

压缩的思路,就是第 16 篇讲过的"交接文档",只不过是自动的:让模型把到目前为止的对话写成一份摘要,然后用这份摘要替换整段历史。

压缩用的 prompt 在 agent/compact.go 里:

上下文快满了。请把到目前为止的对话写成一份交接摘要,下一个会话只能看到这份摘要。

按下面的结构写,只写接手需要的信息,不要复述过程:

## 任务目标
用户最初要求做什么,验收标准是什么。

## 已完成
已经做了哪些修改(文件路径 + 一句话),哪些已经验证过。

## 关键发现
读代码、跑命令时发现的重要事实:项目结构、相关文件和函数、报错原文、踩过的坑。

## 进行中 / 下一步
做到一半的事情,以及接下来第一步该做什么(具体到文件和命令)。

## 用户偏好
用户明确提出过的要求或拒绝过的做法。

和第 16 篇的交接模板对比一下,结构几乎一样。有几处是专门为"自动压缩"设计的:

  • "下一个会话只能看到这份摘要":让模型明白,摘要里没写的东西就永远丢了;
  • "报错原文":压缩之后,那些命令输出都没了,如果关键的报错信息不在摘要里,就得重新跑一遍命令;
  • "用户偏好":第 16 篇开场那个"不要动接口签名"的约束,最容易在压缩时丢失。专门留一节,提醒模型把它们写下来。

4.2 压缩的实现

func (a *Agent) Compact(ctx context.Context) error {
    if len(a.Messages) == 0 {
        return nil
    }
    // 在历史末尾追加压缩指令。用一份拷贝,压缩失败时原历史不受影响。
    msgs := append([]llm.Message(nil), a.Messages...)
    instr := llm.TextBlock(compactPrompt)
    if last := msgs[len(msgs)-1]; last.Role == llm.RoleUser {
        msgs[len(msgs)-1] = llm.Message{Role: llm.RoleUser, Content: append(append([]llm.ContentBlock(nil), last.Content...), instr)}
    } else {
        msgs = append(msgs, llm.Message{Role: llm.RoleUser, Content: []llm.ContentBlock{instr}})
    }

    req := &llm.Request{
        System:     llm.SystemText(a.System),
        Messages:   msgs,
        Tools:      a.Tools.Definitions(), // 历史里有 tool_use 块,工具定义得带上
        ToolChoice: &llm.ToolChoice{Type: "none"},
        MaxTokens:  8000,
    }
    // ……调用模型
    a.Messages = []llm.Message{llm.UserText(
        "(以下是之前对话的交接摘要,原始对话已因上下文过长被压缩。)\n\n" + summary +
            "\n\n请根据摘要继续工作。如果需要文件的具体内容,重新读取。")}
    a.lastContext = 0
    a.UI.Text(fmt.Sprintf("(上下文已压缩:%d 条消息 → 1 条摘要)", before))
    return nil
}

逐个看那些细节:

用一份拷贝。 压缩指令追加在拷贝上,不是追加在原历史上。如果压缩请求失败了(网络断了、模型返回了空内容),原来的历史完好无损,下一圈还能继续。

压缩指令放在最后一条 user 消息里。 如果最后一条已经是 user(比如刚追加了工具结果),就合并进去,保持 user / assistant 交替(第 24 篇)。

工具定义要带上,但不许调用工具。 历史里有 tool_use 块,API 要求请求里必须有对应的工具定义,否则会报错;但这一次我们只要摘要,不要它去调工具,所以 ToolChoice 设成 none。

用一条 user 消息替换整段历史。 压缩之后,历史里只剩这一条。这样做有几个附带的好处:

  • 不存在"tool_use 没有对应的 tool_result"的问题,因为一个 tool_use 都没有了;
  • 不存在"思考内容必须回传"的问题(第 12 篇,DeepSeek 带工具时要求回传之前每一轮的 reasoning_content),因为一条 assistant 消息都没有了;
  • 整段替换,而不是修改中间的某几条。第 10 篇说过,修改历史中间的消息会让缓存失效,而整段替换之后,新的前缀从这一刻开始重新积累缓存。

最后一句提示"如果需要文件的具体内容,重新读取"。 压缩之后,那些文件内容都没了,模型如果还要改某个文件,必须重新读,而 edit_file 也会强制它这么做(第 26 篇:改之前必须读过)。

flowchart LR B["压缩前
40 条消息
15 万 token"] -->|"让模型按模板写摘要"| S["摘要
几百到几千 token"] S --> A["压缩后
1 条 user 消息"] A --> C["继续工作
需要时重新读文件"] style B fill:#ffcdd2,stroke:#b71c1c style A fill:#e8f5e9,stroke:#2e7d32

4.3 手动压缩

除了自动压缩,REPL 里也可以随时输入 /compact 手动触发。比如一个阶段的任务做完了,要开始下一个阶段,但又不想完全清空(还需要之前的一些结论),就可以手动压缩一下。

4.4 压缩会丢什么

压缩不是免费的:

  • 细节:文件的具体内容、命令的完整输出、中间走过的弯路。这些大部分是该丢的,但偶尔会丢掉一个后来发现很关键的细节;
  • 模型觉得不重要的东西:摘要是模型写的,它的判断不一定和你一致;
  • 思考过程:之前的思考内容全部没了。

第 16 篇说过,重要的长任务,交接文档比自动压缩可控,因为你能审。自动压缩是兜底,保证 Agent 不会因为上下文太长而崩掉;而不是最佳实践。还有一个办法能减少压缩带来的损失:真正每次都需要的约束,写进 system prompt 或者项目记忆(第 17 篇)。它们不在消息历史里,压缩时不会被丢掉。

4.5 压缩本身也要花钱

压缩不是免费的:它本身就是一次模型调用,而且是最贵的那种。它要把整段 15 万 token 的历史发过去,让模型读完、写一份几千 token 的摘要。

有两件事让这笔钱没那么吓人:

  • 压缩请求的前缀,和上一圈的请求完全一样:同样的 system prompt、同样的工具定义、同样的历史,只是在最后多了一句压缩指令。第 10 篇说过,前缀一样就能命中缓存。Compact 里专门给它也开了缓存,那 15 万 token 的绝大部分,会按缓存命中的价格计费;
  • 它在账本里是单独一行:压缩用的是一个叫 compact 的 Agent 名(trace.WithAgent(ctx, "compact")),第 37 篇的账本会把它和主 Agent 分开统计。你能清楚地看到,这个任务里压缩一共花了多少。

如果你发现账本里压缩的费用高得不正常,多半是预算设得太低,压缩得太频繁:每一次压缩,都要重新积累缓存,每一次都要再读一遍长长的历史。

4.6 另一种思路:只清掉旧的工具结果

整段压缩是一个"大动作":历史里的一切都变成了一份摘要。还有一种更温和的做法:只清理旧的工具结果。

想想历史里最占地方的是什么:几十圈之前读过的文件、跑过的测试输出。这些内容在当时有用,现在大多已经过时了。可以把它们替换成一行占位符,比如"(这里原本是 order.go 的内容,已被清理,需要时请重新读取)",而保留模型的每一次决策和工具调用本身。这样,模型还记得自己做过什么、为什么这么做,只是不再背着那些旧的原文。

一些成品 Agent 和模型 API 提供了类似的能力(截至写作时,名字和细节各家不同)。mini-agent 没有实现它,原因有两个:

  • 它会破坏缓存:第 10 篇说过,修改历史中间的消息,从那个位置往后的缓存全部失效。清理旧结果,正好就是在修改中间的消息。要用它,就得想清楚多久清理一次、一次清理多少,让"省下的 token"大于"失效的缓存";
  • 整段压缩更简单:对一个教学用的 Agent,一个"超预算就整段压缩"的规则,比一套"清理哪些、保留哪些"的策略好理解得多。

这是一个很好的进阶练习:在 Compact 之前加一个"轻度清理"的步骤,先清掉二十圈之前的工具结果,还不够再整段压缩。改完用第 36 篇的评测集比一比,看看哪种策略在通过率和费用上更划算。

4.7 开场那个场景,压缩救不了

回到开场的故事。如果三十万行日志已经在历史里了,压缩请求也要带着它,同样会超长。已经进了上下文的东西,压缩也救不了你。 这就是为什么截断是第一道防线,而且是最重要的一道:它在入口处拦住,而压缩只能事后补救。

五、最后一道防线:最大迭代次数

前面三道防线管的是"上下文有多大",还有一种失控是"转了多少圈"。模型可能陷入一个循环:反复读同一个文件,反复跑同一个测试,在两种改法之间来回横跳(第 16 篇)。每一圈都在花钱,而且压缩只会让它忘掉自己已经试过什么,可能转得更久。

所以 Send 的循环有一个上限:

for step := 0; step < a.MaxSteps; step++ {
    // ……
}
return "", ErrMaxSteps
// ErrMaxSteps 表示循环转满了还没做完。
var ErrMaxSteps = errors.New("步数用完了,放弃")

默认 50 步。用完了就停,告诉用户"步数用完了,放弃"。第 02 篇那句"本系列的精神内核",到这里有了正式的名字。

六、动手:先让它崩,再修好

实验 1:让它崩一次

这个实验要接真实模型才能看到"上下文超长"的报错:先 export DEEPSEEK_API_KEY=sk-...。会读进很多 token,留意费用。在线 Playground 跑不了 mini-agent。

先造一个巨大的日志文件,三十万行,大约 15MB,远远超过 100 万 token:

mkdir -p /tmp/biglog && cd /tmp/biglog
seq 1 300000 | sed 's/^/2026-10-18 12:00:00 INFO request handled id=/' > app.log
echo "2026-10-18 12:00:01 ERROR database connection refused" >> app.log

然后关掉截断,启动 mini-agent:

go run /path/to/agent-basics/cmd/mini-agent -max-output 0

(-max-output 0 表示不截断,这是专门为这个翻车实验留的参数。)

说:"用 cat 看看 app.log 里有没有报错。"(如果不说用 cat,它可能会用 read_file,那样只会读到前 2000 行,2.4 节说的"每个工具自己也有分寸"会先替你挡住。)它 cat 了整个文件之后,下一次请求就会因为上下文超长而失败。然后试试再问一句、试试 /compact,体会一下"已经进去的东西出不来"。最后用 /reset 收场。

实验 2:修好它

不带 -max-output 参数,用默认的截断,再来一次:

go run /path/to/agent-basics/cmd/mini-agent

同样的问题。这一次它 cat 之后,看到的是开头的一部分、一行"中间省略了多少字符,请用 grep 过滤"、以及结尾的部分。那行 ERROR 就在最后,截断保留了它。看看模型是直接从结尾找到了报错,还是按提示用 grep 又搜了一次。

实验 3:看着它自动压缩

把预算设得很小,让压缩早点发生:

go run /path/to/agent-basics/cmd/mini-agent -budget 20000

在配套仓库的根目录启动,让它做一件需要读好几个文件的事,比如"读一下 agent 目录下的所有 Go 文件,总结一下 Agent 的主循环"。每隔几圈输入一次 /usage,看看上下文的增长;超过两万 token 时,你会看到一行"(上下文已压缩:N 条消息 → 1 条摘要)"。压缩之后,问它一个你最开始提过的要求,看看它还记不记得。

实验 4:跑测试

go test ./agent -run 'TestAutoCompactWhenOverBudget|TestTruncateKeepsHeadAndTail|TestMaxSteps' -v

TestAutoCompactWhenOverBudget 用假模型模拟了一次"上下文超过预算":检查压缩请求不允许调用工具、压缩指令追加在最后一条 user 消息里、压缩之后的请求只剩一条摘要消息。

七、踩坑记录:上下文管理的七个坑

坑 1:截断只保留开头。
测试的失败汇总、编译的错误原因、程序崩溃的调用栈,往往都在最后。保留头和尾。

坑 2:截断了,却不告诉模型。
模型以为它看到的是全部,基于不完整的信息下结论。截断时要说明"省略了多少",以及"想看更多该怎么做"。

坑 3:用 InputTokens 判断上下文大小。
开了缓存之后,它只是"没命中缓存的那部分"。用全部输入加上输出。

坑 4:自己按字数估算 token。
偏差可能很大。直接用 API 返回的用量,精确而且免费。

坑 5:压缩失败时,原历史已经被改了。
在原历史上追加了压缩指令,压缩请求失败,历史里多了一条莫名其妙的"请写交接摘要"。用拷贝。

坑 6:压缩时丢了关键约束。
用户一开始说的"不要动接口签名",压缩之后没了。摘要模板里专门留一节写用户偏好;真正重要的约束,放进 system prompt 或项目记忆。

坑 7:指望压缩救回一次巨大的输出。
它已经在历史里了,压缩请求也要带着它。在入口处截断,是唯一可靠的办法。

一句话总结

上下文管理是四道防线:在入口处截断工具输出,保留头和尾并告诉模型怎么缩小范围;用上一次请求的真实用量记账,超过预算就压缩;压缩是让模型按模板写一份交接摘要、用一条消息替换整段历史,失败时原历史不受影响;最后再用最大迭代次数兜底。越靠前的防线越重要,已经进了上下文的东西,压缩也救不了。

flowchart LR I["工具输出"] -->|"① 截断"| C["上下文"] C -->|"② 记账:超预算?"| K["③ 压缩成摘要"] K --> C C -->|"④ 转满 50 圈"| G["放弃"] style I fill:#ffcdd2,stroke:#b71c1c style G fill:#ffcdd2,stroke:#b71c1c

评论

还没有评论,来说点什么吧。

发表评论

Ctrl + Enter 快速提交
Enter 搜索Esc 关闭