Agent里,怎么系统性管理上下文?

真正的工程解法,要从窗口分配、记忆分层、压缩策略、上下文编排到溢出兜底,全链路设计。 一、先搞清楚:Agent 里的上下文问题不止一种 "上下文怎么管理",不要直接认为是"滑动窗口"。 先把上下文问题拆清楚。 因为不同类型的上下文问题,根因不同,解法也不同。 1. 窗口容量不够 这是最直观的问题:上下文窗口就那么大,…

作者:lh

真正的工程解法,要从窗口分配、记忆分层、压缩策略、上下文编排到溢出兜底,全链路设计。

一、先搞清楚:Agent 里的上下文问题不止一种

"上下文怎么管理",不要直接认为是"滑动窗口"。

先把上下文问题拆清楚。

因为不同类型的上下文问题,根因不同,解法也不同。

1. 窗口容量不够

这是最直观的问题:上下文窗口就那么大,但你需要塞进去的东西太多了。

比如你给 Agent 接了三个工具,每个工具返回 2000 token 的结果,加上 1000 token 的 system prompt,再加上 2000 token 的对话历史,光一次调用就吃掉 9000 token。再多来几轮,窗口直接爆掉。

这个问题本质不是"窗口太小",而是"你塞了太多不该塞的东西"。

2. 关键信息被淹没

窗口没爆,但模型"忘了"重要信息。

比如用户在第 2 轮说:"我叫张三,订单号是 ORD-2024-001。"到第 8 轮模型回答:"请问您的订单号是什么?"

用户心态直接崩了。

这类问题叫"Lost in the Middle"——模型对窗口中间位置的信息关注度天然低于开头和结尾。

窗口没溢出,但信息已经丢失了。

3. 工具返回撑爆上下文

Agent 调用工具后,工具返回了 5000 token 的结构化数据。

里面 80% 的字段跟当前任务无关,但全塞进了上下文。

下一轮模型调用时,这些噪声数据占着位置,真正有用的信息反而被挤出去了。

4. 多步推理的上下文膨胀

Agent 规划了 5 步,每步的 Thought + Action + Observation 都留在上下文里。

到第 4 步时,上下文已经膨胀到原来的 3 倍。

更要命的是,前几步的错误推理过程也留在里面,模型看着自己之前的错误继续推理,越跑越偏。

5. 多轮对话的"记忆断裂"

用户跟 Agent 聊了 20 轮,前 5 轮聊产品功能,中间 10 轮聊部署方案,后 5 轮聊价格。

当用户回到产品功能时,Agent 已经完全不记得前面聊过什么了。

这不是窗口不够用的问题,而是缺少跨 session 的持久记忆机制

所以,Agent 的上下文管理不能只说"窗口不够"。

更准确的说法是:Agent 的上下文管理是窗口容量、信息密度、记忆持久性、多步膨胀和信息丢失五类问题的综合工程。

二、总体思路:不要指望一个滑动窗口解决所有问题

管理 Agent 上下文,不能只靠裁旧消息。

滑动窗口要不要用?

当然要。

但滑动窗口只是最粗糙的一层,不是全部。

真正的工程方案至少有四层:

  1. 窗口分配:搞清楚上下文里装了什么,每一部分占多少配额

  2. 记忆分层:把信息分成工作记忆、短期记忆、长期记忆,不同层级不同策略

  3. 压缩策略:当信息太多时,用摘要、裁剪、结构化等方式提高信息密度

  4. 上下文编排:在多步推理中,控制每步塞进上下文的内容,防止膨胀

这四层解决的问题不一样。

窗口分配解决的是"每一部分该占多少位置"。

记忆分层解决的是"什么信息该留在窗口里,什么该存到外部"。

压缩策略解决的是"怎么在有限空间里装更多有用信息"。

上下文编排解决的是"多步推理中怎么不让上下文越来越臃肿"。

面试里能说出这四层,基本就比只会说"滑动窗口"的同学高一档。

三、第一层:窗口分配——先搞清楚上下文都装了什么

滑动窗口可以裁旧消息,但你得先知道上下文里到底装了什么。

很多人调 Agent 的时候只管写 Prompt,从来不看每一轮实际消耗了多少 token。

这就是瞎调。

上下文到底长什么样

一次典型的 Agent 调用,上下文里通常有这些东西:

[System Prompt]          固定开销,200-1000 token
[对话历史]               逐轮增长,每轮 200-1000 token
[工具定义]               每个工具 100-500 token
[当前轮工具返回]          每次调用 100-5000 token
[RAG 检索文档]           每次检索 500-3000 token
[中间推理步骤]           每步 100-500 token
[用户当前输入]            50-500 token

你看,上下文根本不是"对话历史"这么简单。

一个完整的 Agent 调用,上下文里有六七种不同类型的内容。

先做配额管理

工程上第一步不是裁消息,而是给每部分设定配额。

比如:

总窗口:128K token

System Prompt:      ≤ 2K(1.5%)
工具定义:           ≤ 5K(3.9%)
对话历史:           ≤ 32K(25%)
RAG 检索文档:       ≤ 40K(31%)
工具返回:           ≤ 32K(25%)
当前输入+推理:       ≤ 17K(13.6%)

配额不是拍脑袋定的。要根据你的场景来:

  • 知识库问答场景:RAG 检索文档配额要大

  • 工具调用密集场景:工具返回配额要大

  • 长对话场景:对话历史配额要大

监控实际消耗

光有配额不够,还要监控:

// 每轮调用后记录各部分的 token 消耗
type ContextUsage struct {
    SystemPromptTokens  int
    ToolDefTokens       int
    HistoryTokens       int
    RAGDocTokens        int
    ToolResultTokens    int
    InputTokens         int
    TotalTokens         int
    WindowLimit         int
}

func logContextUsage(usage ContextUsage) {
    usagePercent := float64(usage.TotalTokens) / float64(usage.WindowLimit) * 100
    log.Info("上下文使用情况",
        "total", usage.TotalTokens,
        "limit", usage.WindowLimit,
        "percent", fmt.Sprintf("%.1f%%", usagePercent),
        "history", usage.HistoryTokens,
        "rag_docs", usage.RAGDocTokens,
        "tool_results", usage.ToolResultTokens,
    )

    if usagePercent > 80 {
        log.Warn("上下文即将溢出,触发压缩策略",
            "percent", fmt.Sprintf("%.1f%%", usagePercent))
    }
}

有了这个监控,你才知道问题出在哪一部分——是对话历史太多?还是工具返回太大?还是 RAG 文档太啰嗦?

不看数据就调上下文,跟不看仪表盘开车一样危险。

四、第二层:记忆分层——不是所有信息都值得留在窗口里

上下文窗口是稀缺资源。

稀缺资源就不能什么都往里塞。

工程上常见的做法是把记忆分成三层:

工作记忆(Working Memory)

当前任务直接相关的信息,放在窗口里。

比如用户这一轮的输入、当前工具调用的返回、相关度最高的 RAG 文档。

这部分信息"即用即抛",任务完成就可以清理。

短期记忆(Short-term Memory)

跨轮次但同一 session 内需要记住的信息。

比如用户的名字、当前对话的主题、之前已经确认过的决策。

这部分信息不适合一直占着窗口,但也不能丢掉。做法是:

  • 摘要(Summarization) 把历史对话压缩成结构化摘要

  • 摘要放在 system prompt 或窗口开头位置

  • 当需要细节时,用向量检索从完整历史中找回

// 短期记忆:摘要 + 向量检索
type ShortTermMemory struct {
    Summary      string       // 对话摘要,始终在窗口里
    RawHistory   []Message    // 完整历史,存在外部
    VectorStore  *VectorStore // 向量存储,用于语义检索
}

// 每 N 轮触发摘要更新
func (m *ShortTermMemory) UpdateSummary(ctx context.Context, newMessages []Message) {
    m.RawHistory = append(m.RawHistory, newMessages...)

    // 用 LLM 生成对话摘要
    summaryPrompt := fmt.Sprintf(
        "请用 200 字以内总结以下对话的关键信息(用户身份、核心需求、已确认事项):\n%s",
        formatMessages(m.RawHistory),
    )
    m.Summary = callLLM(ctx, summaryPrompt)

    // 同时把原始消息存入向量库,方便后续检索细节
    for _, msg := range newMessages {
        m.VectorStore.Insert(ctx, msg.Content, msg.Metadata)
    }
}

长期记忆(Long-term Memory)

跨 session 需要持久化的信息。

比如用户的偏好设置、历史订单、常用地址。

这部分信息存在数据库里,需要时检索出来放进上下文。

// 长期记忆:持久化存储 + 按需检索
type LongTermMemory struct {
    DB *sql.DB
}

type UserProfile struct {
    UserID      string
    Preferences map[string]string // {"语言": "中文", "风格": "简洁"}
    History     []string          // 最近交互的关键主题
    LastUpdated time.Time
}

func (m *LongTermMemory) LoadUserContext(ctx context.Context, userID string) *UserProfile {
    profile := m.loadFromDB(ctx, userID)
    // 只把当前任务相关的偏好注入上下文
    return profile
}

三层记忆的核心逻辑是:窗口里只放当前最需要的信息,其他信息存在外面,需要时再检索回来。

这跟人脑的工作方式一样——你不会把所有记忆都同时放在脑子里,而是需要什么回忆什么。

五、第三层:压缩策略——当信息太多时,怎么"挤"进去

记忆分层是把信息挪出去。但有时候你就是需要把大量信息塞进窗口。

这时候就得"压缩"——提高信息密度。

策略一:对话摘要压缩

不是把对话历史全裁掉,而是用 LLM 把历史压缩成摘要。

但这里有个坑:摘要丢细节。

所以更好的做法是"摘要 + 关键消息保留":

[系统指令]  ← 始终保留
[对话摘要]  ← 压缩旧消息
[最近 3 轮完整对话] ← 保留最新交互的细节
[用户当前输入]

摘要兜底全局上下文,最近几轮保留细节。这样既有全局视野,又不丢当前交互的精度。

策略二:工具返回裁剪

工具返回 5000 token,里面 80% 是无关字段。

那就别全塞进去。

// 工具返回只保留关键字段
type ToolResultFilter struct {
    KeepFields []string // 只保留这些字段
    MaxLength  int      // 最大 token 数
}

func (f *ToolResultFilter) Filter(rawResult string) string {
    // 方案一:只提取需要的 JSON 字段
    var data map[string]any
    json.Unmarshal([]byte(rawResult), &data)
    filtered := make(map[string]any)
    for _, field := range f.KeepFields {
        if v, ok := data[field]; ok {
            filtered[field] = v
        }
    }
    result, _ := json.Marshal(filtered)

    // 方案二:如果还是太长,让 LLM 做二次提炼
    if countTokens(string(result)) > f.MaxLength {
        result = []byte(callLLM(context.Background(),
            fmt.Sprintf("从以下数据中提取与当前任务最相关的信息,不超过 %d token:\n%s",
                f.MaxLength, string(result))))
    }
    return string(result)
}

策略三:RAG 文档分块策略

很多人 RAG 把 chunk_size 设得很大,觉得"多塞点信息模型能答得更好"。

错了。

chunk 太大,噪声多,有效信息密度低,还占窗口。

更好的做法是:

  • 小 chunk 召回(256-512 token),提高检索精度

  • 大 chunk 扩展:检索到小 chunk 后,把它前后的上下文也带上

  • 只把最相关的 Top-3 给模型,不要 Top-10 全塞

// RAG 文档注入策略
func injectRAGDocs(ctx context.Context, query string, retriever *Retriever) string {
    // 第一步:小 chunk 召回 Top-10
    smallChunks := retriever.Search(ctx, query, 10, 256)

    // 第二步:Rerank,只保留 Top-3
    reranked := rerank(ctx, query, smallChunks, 3)

    // 第三步:扩展为大 chunk
    var docs []string
    for _, chunk := range reranked {
        expanded := retriever.ExpandContext(ctx, chunk.ID, 1024)
        docs = append(docs, expanded)
    }

    return formatDocs(docs)
}

策略四:结构化压缩

如果你需要 Agent 处理超长文档(比如一份 100 页的合同),别把全文塞进去。

做法是:

  1. 离线预处理:先把文档拆成章节,每章生成摘要

  2. 在线检索:用户提问时,先检索相关章节

  3. 逐层展开:先给 Agent 章节摘要,需要细节时再展开具体内容

这叫"金字塔式上下文"——先给概览,按需展开。

六、第四层:上下文编排——多步推理中怎么控制上下文不膨胀

前面三层解决的是"怎么塞"的问题。这一层解决的是"多步推理中上下文越来越胖"的问题。

Agent 在做多步推理时,每一步的 Thought + Action + Observation 都会留在上下文里。

5 步下来,上下文可能膨胀 3-5 倍。

更关键的是:前几步的错误推理也留在里面,影响后续判断。

策略一:滑动窗口只保留最近 N 步

最直接的做法:

保留:
- System Prompt(始终保留)
- 对话摘要(始终保留)
- 最近 3 步推理过程(滑动)
- 当前步骤

丢弃:
- 第 4 步之前的推理细节

但这有个问题:如果第 1 步获取的关键信息第 5 步还需要,丢掉了就出问题。

策略二:推理摘要替代原始步骤

不保留每一步的完整 Thought + Action + Observation,而是每步完成后生成一个推理摘要:

type ReasoningStep struct {
    StepNum   int
    Summary   string   // "第1步:查询了北京天气,结果为晴天 25°C"
    KeyFacts  []string // ["北京今天晴天", "温度 25°C", "风力 3 级"]
    RawThought string  // 仅保留在外部存储,不放入窗口
}

func (s *ReasoningStep) Compress(rawThought, rawAction, rawObservation string) {
    // 用 LLM 生成推理摘要,只保留关键事实
    s.Summary = callLLM(context.Background(),
        fmt.Sprintf("用一句话总结这一步做了什么、得到了什么关键信息:\n思考:%s\n行动:%s\n观察:%s",
            rawThought, rawAction, rawObservation))

    s.KeyFacts = extractKeyFacts(rawObservation)
}

这样,5 步推理过程从 5000 token 压缩到 500 token,关键信息还在。

策略三:中间步骤"阅后即焚"

对于一些不产生持久信息的中间步骤,直接不入上下文。

比如 Agent 调了一个"获取当前时间"的工具,返回 2025-05-28 14:30:00

这个时间下一轮就用不上了,没必要一直占着窗口。

做法是标记哪些工具返回是"持久信息",哪些是"瞬态信息":

type ToolDef struct {
    Name       string
    Persistent bool // true: 结果保留在上下文;false: 阅后即焚
}

var toolRegistry = []ToolDef{
    {Name: "search_knowledge_base", Persistent: true},  // 知识库结果要保留
    {Name: "get_current_time", Persistent: false},       // 时间信息阅后即焚
    {Name: "query_order", Persistent: true},             // 订单信息要保留
    {Name: "calculate", Persistent: false},              // 中间计算阅后即焚
}

策略四:任务分解 + 子上下文

如果一个任务需要 10 步推理,别让一个 Agent 从头做到尾。

拆成 3 个子任务,每个子任务有自己独立的上下文:

主 Agent:拆解任务,分配子任务,汇总结果
  ├── 子 Agent 1:信息收集(独立上下文,完成后只返回摘要)
  ├── 子 Agent 2:分析推理(独立上下文,完成后只返回结论)
  └── 子 Agent 3:生成回复(独立上下文,只接收前面的摘要)

每个子 Agent 只看到跟自己相关的上下文,不会看到前面所有步骤的冗余信息。

这叫"上下文隔离"——复杂任务拆成多个子任务,每个子任务有独立的上下文空间。

七、窗口溢出了,怎么办?

这是面试官最喜欢追问的地方。

因为现实里不管你设计得多好,窗口溢出总会发生。

你不能说:"我设计得很好,不会溢出。"

这句话太假了。

更好的回答是:我承认溢出无法 100% 避免,所以系统要有检测、降级、重试和兜底机制。

1. 先检测

溢出不一定是窗口 100% 用满。

当你发现:

  • 上下文使用率超过 80%

  • 工具返回异常大(超过预设阈值)

  • 推理步骤超过预设上限

  • 连续两轮 token 消耗增速异常

这些信号出来的时候,就要触发干预,不要等模型报错。

2. 再降级

检测到溢出风险后,按优先级逐层降级:

第一优先级:裁剪工具返回。 把工具返回的非关键字段砍掉,只保留核心信息。

第二优先级:压缩对话历史。 把旧消息替换为摘要,只保留最近 3 轮完整对话。

第三优先级:减少 RAG 文档。 从 Top-5 降到 Top-2,或减小扩展 chunk 的大小。

第四优先级:简化系统指令。 去掉非核心的 Prompt 规则,只保留最关键的限制。

第五优先级:终止当前任务。 告诉用户"当前对话较长,建议开启新会话继续"。

// 上下文溢出降级策略
func handleContextOverflow(ctx context.Context, usage ContextUsage) (*Response, error) {
    strategies := []OverflowStrategy{
        {Name: "裁剪工具返回", Priority: 1, Action: trimToolResults},
        {Name: "压缩对话历史", Priority: 2, Action: compressHistory},
        {Name: "减少RAG文档", Priority: 3, Action: reduceRAGDocs},
        {Name: "简化系统指令", Priority: 4, Action: simplifySystemPrompt},
        {Name: "提示新会话", Priority: 5, Action: suggestNewSession},
    }

    for _, s := range strategies {
        newUsage := s.Action(ctx, usage)
        if newUsage.TotalTokens <= usage.WindowLimit {
            log.Info("溢出降级成功", "strategy", s.Name,
                "before", usage.TotalTokens, "after", newUsage.TotalTokens)
            return retry(ctx, newUsage)
        }
    }

    // 所有策略都失败,降级为提示用户
    return &Response{
        Content:        "当前对话内容较多,建议您开启新会话继续。我会保留本次对话的关键信息。",
        NeedNewSession: true,
    }, nil
}

3. 溢出后的恢复

如果最终还是溢出了,不要直接报错让用户看到。

至少要做:

  • 保留对话摘要,新 session 可以继续

  • 告诉用户哪些信息被保留了、哪些可能丢失了

  • 给用户一个"重新开始但保留关键上下文"的选项

八、工程上怎么长期治理上下文

线上兜底只能止血。

真正要把上下文管理做好,还得做长期治理。

1. 先看数据

不看数据就是盲调。至少监控这些指标:

  • 平均上下文使用率:正常流量下窗口用了多少

  • 溢出率:多少比例的请求触发了溢出降级

  • 压缩触发率:多少比例触发了对话压缩

  • 信息丢失率:用户重复提问的比例(说明模型"忘了")

  • 平均推理步数:多步推理平均几步,是否在增长

  • 每步 token 增量:每增加一步,上下文膨胀多少

这些指标不是为了写周报,是为了告诉你问题出在哪。

2. 建立上下文预算

给每类 Agent 任务建立上下文预算:

任务类型上下文预算推理步数上限工具调用上限简单问答8K10-1知识库检索32K1-20-1工具调用64K3-52-5复杂推理128K5-103-8

预算不是限制,是帮你发现异常的基线。

当某个任务的上下文消耗远超预算时,说明要么任务复杂度变了,要么系统哪里出问题了。

3. 把溢出 case 加入回归测试

每次上下文溢出的事故,都要归因:

  • 是工具返回没有裁剪?

  • 是 RAG 文档分块太大?

  • 是摘要策略失效?

  • 是推理步骤没有压缩?

归因之后,把这次 case 加入 eval 集。每次改上下文策略,都要跑回归。

4. 分场景差异化策略

不是所有 Agent 都需要重上下文管理:

  • 闲聊客服:轻量策略,滑动窗口 + 简单摘要就够了

  • 知识库问答:重点优化 RAG 文档注入策略

  • 数据分析 Agent:重点管理工具返回和中间推理

  • 长文档处理:金字塔式上下文 + 子任务隔离

  • 多轮决策 Agent:三层记忆 + 推理摘要

安全等级要跟风险匹配,上下文策略要跟场景匹配。

九、面试怎么答

面试官问"Agent 系统如何管理上下文,如果窗口溢出怎么办",不要只说"我用滑动窗口"。

要体现你知道上下文管理的层次、策略和兜底。

参考回答思路:

"我不会只靠滑动窗口管理上下文。Agent 的上下文管理要分层来做。

第一层是窗口分配。 先搞清楚上下文里装了什么——system prompt、对话历史、工具返回、RAG 文档、推理步骤,每一部分设配额,然后监控实际消耗。不看数据就调上下文是盲调。

第二层是记忆分层。 把信息分成工作记忆、短期记忆、长期记忆三层。窗口里只放当前最需要的信息,其他存到外部。短期记忆用'摘要 + 向量检索'——摘要放在窗口里兜底全局,需要细节时从向量库检索原始历史。长期记忆持久化到数据库,按需加载。

第三层是压缩策略。 对话历史用摘要压缩代替全量保留;工具返回只保留关键字段,非核心信息裁剪;RAG 文档用小 chunk 召回 + Rerank + 大 chunk 扩展,只给模型最相关的 Top-3;超长文档用金字塔式上下文——先给摘要,按需展开。

第四层是上下文编排。 多步推理中,每步只保留推理摘要而非原始 Thought + Action + Observation,中间步骤标记'阅后即焚',复杂任务拆成子 Agent 各自独立上下文。

如果这些策略之后窗口还是溢出,不能直接报错。先检测——上下文使用率超过 80% 就预警;再降级——按优先级逐层裁剪:先裁工具返回、再压缩历史、再减少 RAG 文档、最后简化 Prompt;如果还是溢出,降级为提示用户开新会话,同时保留对话摘要。

长期治理要监控上下文使用率、溢出率、信息丢失率,建立上下文预算基线,把溢出 case 加入回归测试,分场景采用不同的上下文策略。"

这个回答的重点不是承诺"Agent 上下文永远够用"。

而是告诉面试官:我知道上下文一定可能不够用,所以我有分层管理、压缩策略和溢出兜底。