一行代码让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|>,会带来两个问题:

  1. 角色错位:模型训练时学到的是 tool 角色承载工具结果,强行改成 user,模型要额外花注意力去猜“这行是用户说的还是工具返回的”,容易出现忽略工具结果、重复调用工具等异常。
  2. 前缀漂移块本身是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_contentcontenttool_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块,那么:

  1. 缓存层面:thinking block属于上下文的一部分,剥离后前缀变化,KV Cache失效;
  2. 协议层面:官方对“是否必须回传”的规则分两种情况:
    • 工具调用场景(严格必须):官方文档明确要求,使用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杀手排查清单

  1. 前缀中有没有动态文本(时间、随机ID、用户状态)
  2. 是否对历史消息做过排序、过滤、改写
  3. 工具定义是否在运行时重新生成或重排序
  4. 换行符、行尾空格、编码是否统一
  5. 摘要结果是否每轮重新生成
  6. 消息是否被自行拼成纯文本字符串
  7. 请求是否经过中转站或代理
  8. 是否在会话中切换了模型
posted @ 2026-09-02 21:30  人才瘾大  阅读(5)  评论(0)    收藏  举报