一行代码让Agent账单翻10倍:KV Cache友好的Agent设计
有没有遇到过这种情况:Agent功能都对,但跑起来又慢又贵。首Token延迟忽高忽低,月底账单吓人。
这大概率是Agent 把kvcache打穿了。
我见过一个客服Agent的case,在system prompt末尾拼了一句当前时间:2026-xx-xx xx:xx:xx。就这一行,导致每次请求前缀都不一样,缓存命中率长期个位数。当把时间戳挪到对话末尾的user消息后,命中率飙到85%,首Token延迟降40%,账单砍掉一大半。
其实就是kvcache在作怪。
1. KV Cache是什么,为什么前缀这么敏感
KV Cache就是模型的“草稿纸”
大模型每生成一个新token,都要拿它的query去和前面所有token的key做匹配,再用value加权求和。如果不缓存,每生成一个token都要把前面所有token重算一遍,上下文越长,计算量增长得越快。
KV Cache做的事很简单:前面算过的key和value存下来,后面直接复用。新token只算自己的kv,前面都是“抄作业”。
前缀变一个字,后续缓存全废
注意力有一个硬约束:每个token的KV只依赖它前面的的token。也就是说,前缀只要变了一个字,从这个字往后,所有缓存全部作废,必须重算。
跨请求缓存:省钱的“草稿纸”
kvcache是单次请求内的机制;而openAI的Prompt Caching、Claude的Prompt Caching、deepseek的Context Caching,是把这个缓存跨请求保存下来,读取成本大约是首次计算的1/10。
所以前缀稳定,既降延迟,又降成本。
下面就讲一下如何做到前缀稳定的三条铁律。
2. 三条铁律:缓存友好的基础
2.1 铁律一:系统提示词和工具定义一旦确定就不要改
系统提示词和工具定义位于上下文最前端,是静态前缀的主体。哪怕只动一个空格、一个换行符、一个动态变量,缓存也会全废。
2.1.1 最常见的错误写法
# ❌ 烧钱写法
system_prompt = f"""你是智能客服Agent。
当前时间:{datetime.now()}
用户ID:{user_id}
规则如下:
1. xxx
2. xxx
{tool_definitions}
"""
时间戳、用户ID、会话ID全拼在system prompt里,每次请求前缀都不一样,缓存永远命中不了。
2.1.2 正确做法
# ✅ 缓存友好写法
system_prompt = """你是智能客服Agent。
规则如下:
1. xxx
2. xxx
""" # 完全固定,字节级不变
messages = [
{"role": "system", "content": system_prompt},
{"role": "user", "content": f"用户ID:{user_id}"}, # 追加
{"role": "user", "content": f"当前时间:{datetime.now()}"}, # 追加
]
把system prompt当成“宪法”,一旦颁布,绝不修改。要补充的动态信息,作为新消息追加到末尾。
2.1.3 工具定义同理
工具schema通常占大量token,顺序变了hash就变。所以工具定义不仅内容要固定,顺序也要固定。别搞“按使用频率动态排序”这种骚操作,收益几乎可以忽略,缓存直接报废。
2.2 铁律二:动态信息永远追加到末尾
在上下文末尾追加新内容,不会改变任何已缓存token的KV。所以所有动态信息一律放消息列表末尾,例如:
- 当前时间 → 追加为一条消息
- 工具调用结果 → 追加为tool角色消息
- Agent运行时状态 → 追加到末尾
- 用户特定信息(用户名、偏好、订单号)→ 追加到动态区
注意:滑动窗口是反面教材: 它只保留最近N条、丢最前面的消息,会改变所有后续消息的位置,缓存全废。更糟的是Agent会“失忆”:早期调用的工具结果被滑出窗口后,Agent就相当于忘记了。
2.3 铁律三:使用标准API格式,不要自行拼接消息
有些Agent把结构化的role-content消息转成纯文本流:
USER: 今天的天气?
ASSISTANT: 我查一下。
TOOL: 北京,晴天,32℃
这样做有两个问题。
2.3.1 破坏模型的结构化解析能力
模型在训练时学的是基于role的格式,转成纯文本后,它得额外花注意力去猜这行是谁说的,然后开始出现各种异常行为:重复执行操作、忽略工具结果、该调工具却输出文本。
2.3.2 破坏思维链保留机制:以Qwen3为例
Qwen3使用ChatML作为原生格式,并在标准角色(system/user/assistant/tool)之外,增加了…来表示模型的内部推理、<|tool_call|>来表示工具调用。
当开启思考模式(enable_thinking=True)时,无论用户是否用/think或/no_think切换,模型输出始终会被包裹在…块中;如果思考被关闭,这个块的内容为空。
来看一个具体的多轮工具调用例子。假设用户问“北京今天天气如何”,Qwen3(参考文档)在ChatML下的输出是这样的:
<|im_start|>system
你可以调用工具:get_weather(city) 获取天气
<|im_end|>
<|im_start|>user
北京今天天气如何?
<|im_end|>
<|im_start|>assistant
<|tool_call|>[{"name": "get_weather", "arguments": {"city": "北京"}}]<|tool_end|>
<|im_end|>
<|im_start|>tool
北京天气:晴,25℃
<|im_end|>
如果代码把最后这条工具结果错误地拼成了<|im_start|>user 北京天气:晴,25℃<|im_end|>,会带来两个问题:
- 角色错位:模型训练时学到的是
tool角色承载工具结果,强行改成user,模型要额外花注意力去猜“这行是用户说的还是工具返回的”,容易出现忽略工具结果、重复调用工具等异常。 - 前缀漂移:
…块本身是assistant消息的一部分。如果为了“干净”把历史assistant消息里的…块剥离掉再发下一轮,那么发出去的上下文和上一轮收到的上下文前缀就不一致了,kvcache直接失效。
正确的做法:使用Qwen-Agent或vLLM等支持函数调用的框架,让框架来处理ChatML转换;工具结果严格使用tool角色;历史里的…块不会随意剥离(开启思考时它始终存在,剥离反而造成前后不一致)。
2.3.3 结论
使用官方SDK或标准API格式,让推理框架处理Chat Template转换,不要自己去拼字符串。
2.4 不同模型对历史思维链的处理差异
上面以Qwen3为例讲了ChatML的思考块。但不同模型对“历史里的思维链该怎么处理”策略完全不同,写Agent时必须分别对待。
2.4.1 DeepSeek:工具调用轮必须完整回传reasoning_content
DeepSeek在思考模式下,思维链通过reasoning_content字段返回,与content同级。
官方文档给出了两条明确规则:
- 普通多轮对话(无工具调用):两轮user消息之间,上一轮assistant的
reasoning_content可以不传,即使传了,API也会忽略。 - 涉及工具调用的轮次:中间assistant的
reasoning_content必须完整回传给API,且在后续所有user交互轮次中都要回传。如果没正确回传,API会直接返回400报错。
具体例子。假设用户问“北京明天天气怎么样”,DeepSeek需要先调用get_date拿到日期,再调用get_weather:
Turn 1a: User: "北京明天天气怎么样?"
→ Model: reasoning_content="我需要先获取明天的日期..."
tool_call: get_date()
Turn 1b: Tool result: "2026-06-13"
→ Model: reasoning_content="明天是6月13日,现在调用get_weather"
tool_call: get_weather(location="Tokyo", date="2026-06-13")
Turn 1c: Tool result: "Rainy, 15°C"
→ Model: reasoning_content="下雨且凉爽,用户应该带伞"
content: "是的,带上伞!明天北京有雨,气温约15°C。"
在这个工具调用循环里,Turn 1a和1b的reasoning_content必须在Turn 1c以及用户后续的新问题中全部回传。代码上最简单的做法就是直接messages.append(response.choices[0].message),把整个message对象(包含reasoning_content、content、tool_calls)原样追加,不要手动剥字段。
2.4.2 Claude:思考块必须原样回传,且带有签名
Claude(参考文档)的extended thinking会生成thinking类型的block,且每个thinking block都带一个signature字段。
在“思考 + 工具调用”的多轮工作流中,官方文档明确的行为是:
- 模型在发起工具请求之前展示思考内容;
- 收到工具结果后,模型不会立即重复思考过程;
- Claude要等到下一个非
tool_result的user turn,才会输出下一个thinking block。
具体例子。用Claude查北京天气:
# 第一次请求
resp = client.messages.create(
model="claude-sonnet-4-6",
thinking={"type": "enabled", "budget_tokens": 2048},
tools=[weather_tool],
messages=[{"role": "user", "content": "北京今天天气怎么样?"}]
)
# resp.content = [thinking_block, tool_use_block]
# 第二次请求:必须把thinking_block和tool_use_block一起原样回传
messages = [
{"role": "user", "content": "北京今天天气怎么样?"},
{"role": "assistant", "content": resp.content}, # ← 完整回传,不能只留tool_use
{"role": "user", "content": [{"type": "tool_result", ...}]}
]
如果在构建第二次请求时,把resp.content里的thinking block过滤掉、只留tool_use块,那么:
- 缓存层面:thinking block属于上下文的一部分,剥离后前缀变化,KV Cache失效;
- 协议层面:官方对“是否必须回传”的规则分两种情况:
- 工具调用场景(严格必须):官方文档明确要求,使用extended thinking配合工具调用时,必须把完整的thinking block(含signature)原样回传。不回传或篡改signature,提供方会拒绝该请求。
- 普通多轮对话(建议回传):如果只是普通聊天、不涉及工具调用,thinking block理论上可以省略,API也不会报错,但官方依然建议始终原样回传,我认为这是最简单、最不易出错的做法。
Claude的建议是:把assistant消息的
content数组原样存下来,不要拍平成{role, text}。需要展示给用户时,再从text block里派生出显示字符串。
2.4.3 小结
| 模型 | 历史思维链处理方式 | 不遵守的后果 |
|---|---|---|
| Qwen3 | ChatML中 … 块始终存在(开启思考时),工具结果用tool角色 |
角色错位导致模型行为异常;前缀不一致导致缓存失效 |
| DeepSeek | 无工具调用的轮次:reasoning_content可不传;有工具调用的轮次:必须完整回传 |
工具调用轮不回传 → API 400报错 |
| Claude | thinking block带signature;工具调用场景必须原样回传;普通多轮建议回传;工具结果后不会立即重复思考 |
工具调用场景剥离或篡改 → 请求被拒绝;普通多轮剥离 → 不报错,但丢失思考上下文,影响质量;两种情况都会导致缓存断点失效 |
总结一个共同点:“自己拼字符串”或“拍平历史消息”,单模型单轮时似乎能跑通,一旦涉及多轮工具调用、跨模型适配,就容易会出问题。
铁律三要求用标准API格式,这是三类模型用报错、请求拒绝和缓存失效换来的工程共识。
3. 上下文怎么组织:稳定前缀 + 动态后缀
3.1 四段式结构
把三条铁律落地,Agent的prompt应该长这样:
[System]
系统提示词(固定不变)
[Tools]
工具定义/schema(固定不变)
[Examples]
静态示例/few-shot(固定不变)
[History + Current]
动态对话历史 + 当前问题(追加在后)
前三段构成稳定前缀,最后一段是动态增量。缓存断点画在第三段和第四段之间。
第一轮完整计算,后续每轮只需计算新增的少量token,其余全部命中缓存。
3.2 长对话怎么办:摘要+截断,不要滑动窗口
上下文长度迟早爆窗。正确的做法是摘要压缩 + 滚动截断:
[summary] 用户询问订单退款流程,助手提供了退货地址。 ← 压缩后的摘要
[recent] 用户:我还没收到退款。 ← 最近几轮完整保留
助手:请提供订单号。
用户:订单号是12345。
摘要本身要稳定生成,最好把摘要结果也缓存起来,避免每轮重新生成。滚动截断时,从最前面整体截掉一批,剩余部分顺序不变。
3.3 多用户、多Agent场景下,缓存能共享吗
默认不共享,但可以设计成共享。KV Cache的复用条件是前缀字节级一致,所以:
3.3.1 跨用户共享
所有用户共用同一份system prompt和工具定义,不注入用户特定信息。这些用户在system和tools这一段的前缀完全一致,服务商可以复用这一段的缓存。用户特定信息全部放动态区。
3.3.2 跨Agent共享
多Agent系统里,所有子Agent共用一段“平台级system prompt”(安全规则、通用能力声明),后面再接各自角色定义。这段平台前缀在所有子Agent间复用。
3.3.3 一个反直觉的结论
给每个用户定制system prompt看起来体验更好,但定制化程度越高,命中率越低。把定制内容放在动态区,既保留个性化,又不破坏前缀共享。
4. 几个容易忽略的缓存杀手
4.1 effort参数乱换
reasoning_effort如果作为API独立参数传入,输入前缀不变,缓存正常命中。但如果把它拼进system prompt,比如你是一个推理强度为high的Agent,前缀就变了,缓存全挂。
原则:effort永远是API参数,永远不进prompt。
4.2 中转站代理
走API中转站,缓存命中率基本趋近于零。中转站会注入自己的系统提示词、加用户标识、重写消息格式,前缀早就不是你发的那个了。上游看到的“新”prompt,和原始的hash完全不同。
中转站宣传的“缓存”通常是响应缓存(完全相同请求直接返回上次结果),不是KV Cache。Agent的请求几乎不会完全相同,命中率极低。
建议:在意缓存利用率,尽量直连官方API。如果必须走中转,选“纯净转发”类型,并定期对比直连和中转的
cached_tokens验证。
4.3 会话中切换模型
GPT切到Claude,即使输入完全相同,缓存也必然失效,因为不同模型的KV Cache是各自独立的。多模型路由的Agent,建议按任务类型固定模型,避免同一会话内频繁切换。
5. 用数据说话:建立缓存可观测性
没有度量就没有优化。上线前把三个指标接进日志:
prompt_tokens:本次实际需要计算的token数cached_tokens:命中缓存的token数cache_hit_rate=cached_tokens/(cached_tokens+prompt_tokens)
OpenAI、Claude、DeepSeek的API响应里都有这些字段,把缓存状态和请求ID一起记下来。
kvcache杀手排查清单
- 前缀中有没有动态文本(时间、随机ID、用户状态)
- 是否对历史消息做过排序、过滤、改写
- 工具定义是否在运行时重新生成或重排序
- 换行符、行尾空格、编码是否统一
- 摘要结果是否每轮重新生成
- 消息是否被自行拼成纯文本字符串
- 请求是否经过中转站或代理
- 是否在会话中切换了模型

浙公网安备 33010602011771号