深入理解 AI Agent(二):上下文的结构与 KV Cache

李博杰《深入理解 AI Agent》第2章上半:上下文=静态前缀+轨迹、API 四角色与无状态机制、KV Cache 三条铁律、缓存作为架构约束

深入理解 AI Agent(二):上下文的结构与 KV Cache

资料源:李博杰《深入理解 AI Agent:设计原理与工程实践》第2章(2.1-2.3 节) 开源仓库:https://github.com/bojieli/ai-agent-book (v1.4,2026-08-13)

一句话主线

第1章说上下文是 Agent 的"眼睛"。第2章回答怎么设计这双眼睛——作者把整套方法论压缩成一个结构和一个约束:

结构:上下文 = 静态前缀(系统提示词 + 工具定义)+ 轨迹(消息历史) 约束:前缀不能动(KV Cache 的经济性),动态信息追加末尾

这篇(二)讲结构和约束本身:上下文为什么决定能力上限、API 层面长什么样、KV Cache 为什么把"前缀稳定"变成一条架构铁律。

上下文决定能力上限:天才工程师的困境

模型在标准测试里成绩亮眼,到了业务场景却常常让人失望。原因不是模型笨,而是通用模型不知道你的业务背景。书的比喻很到位:想象一位天才工程师加入你的团队——理论功底深厚、编程能力卓越,但对产品架构、业务逻辑、技术债务一无所知。这位天才即便智力超群,也难以发挥价值。

以 Coding Agent 为例,“帮我修复这个 bug"的效果,完全取决于 Agent 拿到什么:

  • 实时代码上下文:目录结构、模块职责、核心数据结构、代码规范——没有这些,代码语法正确但风格与项目格格不入
  • 流程规范:Git 分支策略、提交规范、CI/CD 要求——缺少这些,Agent 可能直接往主分支提交未经测试的代码
  • 环境信息:开发环境配置、测试数据库地址、API 密钥管理——没有这些,本地能跑通的修复,到测试环境立刻崩溃

这三类信息构成了 Agent 有效工作的最低信息需求。作者引用了 OpenAI 研究员翁家翌的观点,把这个判断推到极致:“人和模型一样,最重要的是 Context”——“自己在 OpenAI 的工作也没有那么难,如果换一个其他人,如果有他所有的 context,也是能干的。“团队合作中最大的问题是 context 的不一致;AI 短时间内无法取代人的最大原因也是 context——因为 AI 跟人并不在同一个环境里面。

一个直接推论:一个中等能力的模型配上精心组织的上下文,往往胜过顶级模型在信息匮乏下的盲目摸索。 这解释了为什么上下文工程成为利用现有模型开发高效 Agent 的关键。

书还点出一个容易被忽视的层面:上下文工程不只是技术问题,更是组织问题。大多数团队的关键知识是隐性的——架构决策只有老员工记得、业务规则靠口口相传、背景信息锁在私聊记录里。如果团队本身就是信息黑洞,再好的 Agent 也无计可施。对远程工作友好的团队(如 Linux 内核——全球开发者协作三十多年,靠高度透明、文档驱动的沟通文化)天然对 AI Agent 友好。构建 AI 原生团队,首先是一场文档化运动。

API 视角的上下文:无状态接口 + 消息列表

Agent 每次调用模型,本质上是发送一个消息列表(messages)。四种角色:

角色来源作用
system开发者编写系统提示词,最高优先级指令,通常只有一条,放最前面
user终端用户用户的输入
assistant模型之前生成文本回复 + 工具调用请求,放回列表让模型"记住"自己说过什么
toolAgent 框架工具执行结果,通过 tool_call_id 与对应调用关联

另外,工具定义(tools)是请求的独立字段而非消息角色。“四种消息角色 + tools 字段"恰好覆盖第1章说的五个上下文组成部分——系统提示词、用户消息、模型回复、工具执行结果(四角色)+ 工具定义(tools 字段)。

关键机制:模型 API 是无状态的。 每次调用都必须由 Agent 框架把完整历史送回去。模型不会"记住"上一次对话——Agent 框架的核心工作,就是管理这个 messages 列表:在合适的时机追加消息,然后把整个列表送给模型。本章后续所有技术,本质上都是在优化这个列表的内容和结构。

书中用"温哥华时间和天气"完整走了一遍两次 API 调用的交互(这是 ReAct 循环在 API 层面的具体实现):

  1. 第一次调用:system + user 请求。模型返回两个并行工具调用(get_current_time + get_weather)——两个子问题无数据依赖,模型在一次输出中同时生成两个请求
  2. Agent 框架执行工具:真正调用时间 API 和天气 API 的是框架,不是模型。模型只负责决策(调什么、传什么参数)
  3. 第二次调用:把完整历史 + 两个 tool 结果送回。模型看到结果,判断信息已足够,直接输出最终回复,循环结束

三个关键细节:第二次请求包含第一次的全部历史(无状态);第一次的 assistant 消息被原样放回(模型能看到自己的决策);tool 消息通过 tool_call_id 关联(模型知道哪个结果对应哪个调用)。

最简实现就是一个 while 循环:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
while True:
    response = client.chat.completions.create(messages=messages, tools=tools, ...)
    assistant_message = response.choices[0].message
    messages.append(assistant_message)          # 模型回复原样放回

    if not assistant_message.tool_calls:        # 无工具调用 = 最终回复
        print(assistant_message.content)
        break

    for tool_call in assistant_message.tool_calls:
        result = execute_tool(tool_call.function.name, tool_call.function.arguments)
        messages.append({"role": "tool", "tool_call_id": tool_call.id, "content": result})

注意注释里那句:生产代码必须加 max_iterations 上限——否则 Agent 可能卡在重复调用同一个工具的死循环里。这是第1章消融实验"缺工具结果 → 无限循环"的工程化后果,也是第1章思考题 Q4 讨论的循环问题的第一个具体答案。

实验 2-1 还展示了另一个反直觉的事实:0.6B 的超小模型,在合理提示词和架构下也能可靠完成工具调用(作者在 M2 芯片上跑出每秒 100+ token)。模型大小固然重要,但不是唯一决定因素——端侧 Agent 的时代比大多数人预期的更近。

Chat Template:API 消息到模型 Token 流的转换

一个重要的中间环节:API 层面的结构化 JSON 消息,并不是模型直接处理的形式。API 服务端(vLLM、Ollama 等)会根据模型的 Chat Template(聊天模板),把消息列表转换为模型实际处理的线性 token 流——用特殊标记(如 <|im_start|>system<|im_end|>)界定每条消息的角色和边界。可以想象成信封格式:API 消息是信的内容,Chat Template 规定了如何在信封上写明寄件人、收件人。不同模型家族(Qwen、Llama、Gemma)使用不同的"信封格式”。

理解它的存在对 Agent 开发有两个实用价值:

第一,解释了为什么必须使用标准 API 格式。如果开发者绕过 API、自行拼接消息(比如把工具结果作为普通 user 消息而非 tool 类型传递),Chat Template 会误将工具响应识别为新的用户查询。以 Qwen3 为例:模型在多轮工具调用中会把之前的内部思考过程(<think> 标签内容)保留下来,像草稿纸上的推导步骤;但当 Chat Template 检测到新的用户查询时,会默认"用户换了个话题”,清理之前的思考过程重新开始。工具结果被错误标记为用户消息,就会误触发这种清理——模型正算到一半,草稿纸被收走了,只能从头再来。

不同模型家族对历史思考链的处理策略差异很大且快速演变:DeepSeek R1 时代是剥离全部历史思考(R1 训练时历史 CoT 从不出现在输入里,塞回去属于分布外输入,且省 token);DeepSeek V4 则彻底反转,强制要求把每轮 assistant 消息(包括带 tool_calls 的)的 reasoning_content 原样回传,否则直接报错——Kimi K2、GLM-5 也采用同样协议;Claude 要求客户端在工具调用循环中把 thinking block(带签名校验)原样回传。选择模型时务必查阅对应模型的最新文档。

第二,解释了 KV Cache 为什么对前缀如此敏感。Chat Template 把 system 消息和工具定义转换为固定的 token 序列放在最前面,这些 token 的键值对缓存后可跨请求复用;但前缀中某个 token 一旦变化——哪怕多一个空格——首个不同 token 及其后的缓存就无法复用。

KV Cache:为什么"前缀不能动"是一条铁律

事故开篇:一行时间戳,账单翻倍

某团队的客服 Agent 每天处理 10 万次对话,原本一切正常。某天工程师为了让 Agent “知道"当前时间,在系统提示词里加了一行 Current time: {{now}}。第二天监控告警:所有对话的首 token 延迟从 0.5 秒涨到 3-5 秒,月度推理账单几乎翻了一倍。代码看起来完全没问题,模型也没换——问题出在哪?

答案是:那一行时间戳使每次请求的 token 序列从时间戳所在位置开始不同,因此该位置及其后的 KV 状态无法复用。系统提示词位于上下文前部,模型不得不重新计算它之后的大部分 token。开发者写下的一行看似无害的代码,可能让整条推理链路慢一个量级。

注意力机制:为什么需要缓存

先建立直觉(实验 2-2)。假设模型正在处理"北京的天气怎么样”,读到"怎么样"时,需要决定前面哪些词最重要。注意力机制用三个向量完成:

  • Query(查询):当前词发出的"搜索请求”——“怎么样"问:哪个词和我最相关?
  • Key(键):每个词的"标签”,用于被搜索匹配——“北京"的标签偏向"地名”,“天气"偏向"气象”
  • Value(值):每个词的内容,匹配成功后被提取

每个新词生成时,它的 Query 要与前面所有词的 Key 做点积打分,再用权重对所有 Value 加权求和。每生成一个新词,都要"回头看"前文所有 token 的中间计算结果。如果不缓存,第 N 个 token 要重算前面 N-1 个 token 的 K、V——总计算量与 N² 成正比。KV Cache 就是把已算过的 K、V 缓存起来,新词直接复用。

但注意:KV Cache 省去的是历史 token 的 K、V 投影重算,每个新 token 的注意力计算仍要遍历全部缓存的 K、V——这就是长上下文解码越来越慢、KV Cache 的显存与带宽成为推理瓶颈的原因。

注意力热力图还揭示了两个值得知道的模式:

  1. 注意力储存池(Attention Sink):序列第一个 token 往往吸收异常高的注意力权重(有时超 70%)。原因是 softmax 硬性要求所有权重加起来等于 100%,模型无法表达"不关注任何东西”,于是把剩余权重集中倾倒到第一个 token 上——像公共回收站,是数学必然,不是模型缺陷
  2. 位置偏好(Position Bias):模型对上下文开头和结尾的信息分配更高注意力,中间部分更容易被忽视——所以把最关键信息放在开头或结尾是重要的设计原则

三条铁律

理解 KV Cache 后,书给出三条核心结论(即使跳过所有技术细节也要记住):

  1. 系统提示词和工具定义一旦确定就不要改——哪怕多一个空格,都使首个不同 token 及其后的缓存无法复用;改动越靠前,延迟和成本影响越大
  2. 动态信息永远追加到末尾——时间戳、用户状态等变化内容,作为新消息追加到对话末尾,而不是修改已有的系统提示词
  3. 使用标准 API 格式,不要自行拼接消息——结构化消息会被 Chat Template 翻译成模型训练时见过的固定 token 序列;自己拼成 “USER:… ASSISTANT:…” 偏离训练格式,会削弱模型的多步思考能力

一个容易混淆的细节:缓存只认 token 字节序列——只要拼接出的前缀字节级稳定,照样能命中;文本格式化真正的问题不是缓存,而是偏离训练格式。

常见但有害的上下文管理模式(实验 2-3)

  • 动态系统提示词(时间戳):破坏缓存(就是上面的事故)
  • 动态用户配置(账户余额等):破坏缓存
  • 工具定义的动态排序:工具定义占大量 token(每个工具数百 token),改变顺序就废缓存——而实验表明保持固定顺序对模型选择工具几乎没影响
  • 滑动窗口(只保留最近 N 条消息):两个严重问题——破坏前缀一致性 + 可能丢失关键工具调用结果。实验中滑动窗口的 Agent 经常陷入循环,因为它"忘记"了之前已经获得的结果,反复执行相同的工具调用
  • 文本格式化(把 role-content 消息转成 “USER:… ASSISTANT:…” 纯文本流):偏离训练格式,导致重复执行已完成的操作、忽略工具调用结果、在应该调用工具时却生成文本响应

小结的教训:这些错误模式的解法最终都收敛回三条铁律。模型提供商为标准接口做了大量优化,偏离标准格式往往是在给自己挖坑。

缓存是架构约束,不是事后优化

KV Cache 与 Prompt Cache 是两个层级:KV Cache 是模型内部机制(单次请求内避免重复计算);Prompt Cache 是推理引擎的跨请求优化(复用相同前缀的计算结果,Anthropic/DeepSeek/GPT-5 缓存读取成本约为首次计算的十分之一)。

在生产级 Agent 系统中,缓存的经济性会反过来主导架构决策(2.3.4,以 Claude Code 为例):

  • 提示词的结构由缓存边界决定:系统提示词被一个缓存边界标记一分为二——标记之前的内容可以跨用户、跨会话全局缓存,标记之后是用户/会话特定信息。每个运行时条件(操作系统、模式、用户偏好)如果放在边界之前,就会让缓存键的变体数量翻倍(N 个二值条件产生 2^N 种组合),所以所有动态元素都必须放到边界之后
  • 子 Agent 必须与父 Agent 字节级对齐:派生子 Agent 时,提示词、工具定义、模型配置、消息前缀、思考配置必须逐字节匹配,才能命中 Prompt Cache,减少费用和延迟
  • 工具结果的替换字符串在首次出现时就被冻结:大工具输出被替换为摘要预览时,替换字符串被持久化保存,即使会话重启也用完全相同的字符串——保证恢复后的消息序列与缓存字节流一致

核心启示:在设计 Agent 架构时,缓存经济性不是事后优化,而是前置约束。 越早纳入架构设计,后续工程代价越小。

研究前沿:KV Cache 未必是一次性的

2.3.5 是深水区选读(初读可跳过)。作者提出一个反直觉的观察:在 prefill 阶段,模型其实在"做笔记"——读到一个字段(“用户所在城市:北京”)时,不是原封不动缓存它,而是把"这意味着什么"的结论写进了后面每一层的 KV 状态。测量发现,一个字段自己那几个 token 的 KV 对最终决策的贡献往往不到 1%——真正影响输出的,是它在下游留下的那些"读书笔记"。

这打开了两种以前认为不可能的操作:

  • 编辑(Editing):既然结论已写进下游笔记,改掉一个字段后,只要模型有显式思考链(CoT),改动就能顺着已缓存的思考传播下去,用约 1% 算力得到与整段重算一致的结果
  • 组合(Composition):把一段预先算好的"技能"缓存,通过旋转位置编码(RoPE)挪到新位置拼接进另一段上下文——上下文组装从 O(L²) 重算降到 O(L) 拼接

论文在 vLLM 上实现后,首 token 延迟(p90)最高降几十到几百倍,前缀缓存命中率约 98.5%,输出与逐字重算在决策上完全一致。它指向一种"上下文可变、但缓存收益还在"的可能——但作者明确说:这仍属研究阶段,当前生产系统依然遵守三条铁律。

思考与延伸:读 KV Cache 时的四个确认

  1. chat template 转换会把 api 看到的 json -> 类似 <|im_start|> 这样标签包裹的内容(为了生成 token 流)。
  1. 必须用标准 api 格式的原因:不同的模型可能有自己的格式。qwen3 会保留内部思考过程()。deepseek r1 剥离全部历史思考。deepseek v4 要求把每轮的 assistant 消息(包括带 tool_calls 的)reasoning_content 原样回传。
  1. kv cache 的原理与约束。prefill 阶段(模型生成回复之前,处理输入的全部 token 的阶段)注意力计算量随上下文长度平方级增长。
  1. KV cache 和 prompt cache 是不一样的,是两个层级的。前者是单次会话内的,后者是多次会话之间复用。

这四条是对"标准 API 格式"和"缓存层级"两个概念的精准确认。其中第 2 条是正文没有展开的细节:不同模型家族对历史思考链的处理策略差异很大且快速演变——R1 剥离(历史 CoT 属分布外输入)、V4 强制回传(否则报错)、Claude 要求带签名校验回传 thinking block。选型时务必查最新文档。

小结

这篇(二)建立的地基:

  1. 上下文决定能力上限——天才工程师需要背景信息才能发挥作用;上下文工程首先是文档化运动
  2. API 是无状态的——Agent 框架的核心工作就是管理 messages 列表;生产代码必须有 max_iterations
  3. KV Cache 让"前缀稳定"成为架构铁律——三条铁律(前缀不动/动态追加末尾/标准 API 格式)+ 缓存是前置架构约束
  4. 滑动窗口是个陷阱——破坏缓存 + 丢关键结果 + 导致重复执行循环

下一篇(三)讲"前缀里到底放什么"——提示工程(写给模型看的员工手册)和提示注入(外部内容劫持上下文的头号威胁)。