Agent(智能体)开发和普通聊天机器人不太一样。普通聊天机器人往往是“一问一答”,而 Agent 通常会经历多轮推理、工具调用、工具结果回填、状态更新,再继续生成下一步动作。
这类系统的一个隐藏成本来自上下文重复计算:每一轮请求都会带上系统提示词、工具定义、历史消息、工具返回结果等大量内容。大语言模型 LLM(Large Language Model)在推理时,如果能复用前面相同 token(词元)序列的 KV Cache(Key-Value Cache,键值缓存),就可以少算一大段上下文,从而降低延迟和推理成本。
从这个角度看,completion 和 chat/completion 的差异不只是接口形式不同,更关键的是:谁控制最终送入模型的 token 序列。
LLM 底层只做 token 续写
Transformer 模型的生成过程可以简化成一个条件概率问题:
P(next_token | previous_tokens)
模型并不知道“对话”“角色”“工具调用”这些抽象概念。它真正看到的是一串 token ID,然后预测下一个 token。
也就是说,不管外层 API(Application Programming Interface,应用程序编程接口)长什么样,最终都要变成类似这样的序列:
[token_1, token_2, token_3, ..., token_n]
completion 和 chat/completion 的核心区别在于:
| 接口 | 开发者提交什么 | 模型实际消费什么 | 谁控制最终 prompt |
|---|---|---|---|
completion | 一个完整字符串 prompt | 由这个字符串分词得到的 token 序列 | 开发者 |
chat/completion | messages 数组 | 服务端套模板后生成的 prompt,再分词 | 服务商 |
这个差异会直接影响 KV Cache 的可预测性。
Completion 接口:直接提交连续文本
completion 是更接近模型本体的接口。开发者传入一个完整 prompt,模型在这个 prompt 后面继续生成文本。
一个极简例子:
输入:
Once upon a time, there was a princess
输出:
who lived in a tall tower...
从模型角度看,这就是纯文本续写。开发者给出一段连续 token 序列,模型生成后续 token。
如果要用 completion 模拟聊天,就需要自己写出模型需要的对话格式。例如某些 ChatML 风格的模型可能使用类似结构:
<|im_start|>system
You are a helpful assistant.
<|im_end|>
<|im_start|>user
Hello
<|im_end|>
<|im_start|>assistant
Hi! How can I help you?
<|im_end|>
<|im_start|>user
Please help me write a Python function.
<|im_end|>
<|im_start|>assistant
使用 completion 的好处是非常直接:构造出来的 prompt 基本就是送入模型的文本。只要分词器和特殊 token 规则确定,最终 token 序列就可预测。
代价也很明显:开发者必须知道目标模型的 Chat Template(聊天模板)。如果模板写错,比如角色标记不对、结束符不对、工具调用格式不对,模型表现可能明显下降。
Chat Completion 接口:提交 messages,由服务端套模板
chat/completion 把对话抽象成 messages 数组:
[
{
"role": "system",
"content": "You are a helpful assistant."
},
{
"role": "user",
"content": "Hello"
},
{
"role": "assistant",
"content": "Hi!"
},
{
"role": "user",
"content": "How are you?"
}
]
这个格式对业务开发很友好。开发者不用关心模型的真实 prompt 模板,只需要按照角色塞消息:
system:系统指令user:用户输入assistant:模型历史回复tool:工具返回结果assistant.tool_calls:模型发起的工具调用
但服务端不会直接把 JSON 数组送进模型。它会先把 messages 转成模型能理解的文本格式,再分词成 token ID。
整体路径如下:
flowchart LR
A[messages 数组] --> B[服务端 Chat Template]
B --> C[actual_prompt 字符串]
C --> D[token ID 序列]
D --> E[模型推理]
E --> F[assistant 输出]
这个转换过程通常不完全透明。不同服务商、不同模型、不同版本可能使用不同模板:
| 模板风格 | 常见特点 |
|---|---|
| ChatML 风格 | 使用 `< |
| Llama-style | 使用 [INST]...[/INST] 或模型专用特殊 token |
| Claude-style | 使用服务商定义的消息块和工具块结构 |
| 自定义模板 | 对工具调用、推理字段、系统消息有额外拼接规则 |
对普通聊天来说,这种抽象很省事;对需要严格控制缓存前缀的 Agent 来说,这层抽象会带来不确定性。
KV Cache 和 Prefix Caching 为什么重要
Transformer 在生成每个 token 时,需要关注前面的上下文。为了避免每生成一个 token 都重新计算全部历史,推理系统会缓存历史 token 对应的 Key 和 Value,这就是 KV Cache。
简化理解:
prompt tokens --> 计算 attention 的 K/V --> 缓存在推理系统中
如果多个请求拥有完全相同的前缀 token,推理服务就有机会复用这一段前缀的 KV Cache。这个机制通常叫 Prefix Caching(前缀缓存)。
例如一个 Agent 连续执行三轮:
第 1 轮:
[系统提示词][工具定义][任务说明] -> 生成工具调用
第 2 轮:
[系统提示词][工具定义][任务说明][工具调用][工具结果] -> 生成下一步
第 3 轮:
[系统提示词][工具定义][任务说明][工具调用][工具结果][中间结论] -> 生成最终答案
前面的 [系统提示词][工具定义][任务说明] 在多轮里完全相同,理论上可以复用缓存。
用图表示更直观:
请求 1:
| 固定前缀 A | 新输入 B |
请求 2:
| 固定前缀 A | 新输入 B | 工具结果 C |
请求 3:
| 固定前缀 A | 新输入 B | 工具结果 C | 新问题 D |
只要 固定前缀 A 的 token 序列完全一致,推理系统就可能直接复用这段 KV Cache。
一个典型 Agent 请求可以拆成这样:
flowchart LR
A[稳定系统提示词] --> B[稳定工具定义]
B --> C[稳定 Few-shot 示例]
C --> D[任务上下文]
D --> E[历史工具调用与工具结果]
E --> F[用户最新请求]
F --> G[模型生成下一步]
A -.可复用前缀.-> C
D -.部分可复用.-> E
F -.新计算.-> G
Prefix Caching 的关键不是“文本看起来差不多”,而是 token 序列必须完全一致。哪怕多一个空格、换行、角色标记、随机 ID,都会导致从变化位置开始无法命中缓存。
Completion 在缓存控制上的优势
使用 completion 时,开发者直接拼出 prompt。哪一段是稳定前缀,哪一段是新增内容,都可以自己安排。
例如可以把 Agent prompt 设计成四段:
[固定系统规则]
[固定工具定义]
[固定输出格式要求]
[动态任务状态与最新输入]
这样稳定内容始终放在最前面:
SYSTEM_RULES = """You are an agent that solves tasks by calling tools.
Rules:
- Think about the next action.
- Use tools only when needed.
- Return final answer when enough information is available.
"""
TOOL_DEFINITIONS = """Available tools:
1. search(query: string) -> SearchResult
2. read_file(path: string) -> FileContent
3. run_python(code: string) -> ExecutionResult
"""
OUTPUT_FORMAT = """When calling a tool, use:
<tool_call>
{"name": "...", "arguments": {...}}
</tool_call>
When answering finally, use:
<final>
...
</final>
"""
def build_prompt(task_state: str, latest_user_input: str) -> str:
return (
SYSTEM_RULES
+ "\n\n"
+ TOOL_DEFINITIONS
+ "\n\n"
+ OUTPUT_FORMAT
+ "\n\n"
+ "Task state:\n"
+ task_state
+ "\n\n"
+ "User input:\n"
+ latest_user_input
+ "\n\n"
+ "Assistant:\n"
)
这种结构有三个好处:
| 特性 | 对 KV Cache 的意义 |
|---|---|
| 前缀透明 | 开发者知道 prompt 的每个字符来自哪里 |
| 顺序可控 | 稳定内容可以固定放在最前面 |
| 命中可预测 | 相同字符串通常会得到相同 token 前缀 |
如果多个 Agent 任务共用同一套系统规则和工具定义,那么这些内容也可以成为跨请求复用的公共前缀。
Chat Completion 的问题:messages 相同不代表 actual_prompt 可控
chat/completion 的输入是结构化 messages,但缓存命中发生在实际 token 序列层面,而不是 JSON 层面。
流程可以写成:
list[dict] messages
-> chat template 渲染
-> actual_prompt 字符串
-> tokenizer 分词
-> token ID 序列
-> 模型推理
开发者能控制的是第一步 messages,但 Prefix Caching 依赖的是最后的 token ID 序列。中间的模板渲染如果不可见,就会带来几个问题。
角色标记和特殊 token 会影响前缀连续性
同样的消息内容,套不同模板后可能完全不同。
messages:
[
{"role": "system", "content": "You are helpful."},
{"role": "user", "content": "Hello"}
]
模板 A 可能渲染成:
<|im_start|>system
You are helpful.
<|im_end|>
<|im_start|>user
Hello
<|im_end|>
<|im_start|>assistant
模板 B 可能渲染成:
[INST] <<SYS>>
You are helpful.
<</SYS>>
Hello [/INST]
两者语义相近,但 token 前缀不同,KV Cache 不能互相复用。
服务端可能裁切历史消息
长上下文场景下,服务端可能对历史消息做裁切、压缩或过滤。尤其是带推理字段的 thinking 模型,历史 assistant 消息里可能包含:
contentreasoning_contenttool_calls- 其他模型专用字段
某些模板在第一次请求时会把可见推理字段写入 prompt,但在后续请求重放历史时不再写回,或者只保留最终 content 和工具调用。
可以用伪代码表示这种差异:
第一次请求:
messages = [
system,
user_1,
assistant_1(reasoning_content + content + tool_call),
tool_result_1,
user_2
]
actual_prompt =
system
user_1
assistant_1 的 reasoning_content
assistant_1 的 content
assistant_1 的 tool_call
tool_result_1
user_2
第二次请求时,如果服务端模板不再保留 reasoning_content:
第二次请求:
messages = [
system,
user_1,
assistant_1(content + tool_call),
tool_result_1,
user_2,
assistant_2,
user_3
]
actual_prompt =
system
user_1
assistant_1 的 content
assistant_1 的 tool_call
tool_result_1
user_2
assistant_2
user_3
这会产生两个影响:
| 问题 | 后果 |
|---|---|
| 前缀 token 变了 | 从变化点开始,KV Cache 难以复用 |
| 历史中间状态少了 | Agent 可能丢失上一轮已经形成的中间判断 |
需要注意,很多服务商不会暴露完整隐藏推理过程,这是产品和安全设计的一部分。Agent 状态不能依赖不可控的隐藏思维链。更稳妥的做法是把必要的中间结论、计划、工具结果摘要显式写入可控上下文。
messages 的 JSON 稳定,不等于模板输出稳定
开发者看到的可能是稳定结构:
{"role": "tool", "content": "{\"temperature\": 27}"}
服务端模板可能做额外处理:
<tool_result>
{"temperature":27}
</tool_result>
也可能变成:
Tool result:
temperature: 27
如果服务商升级模板、调整工具块格式、改变空白字符处理,业务代码没有变化,实际 prompt 仍可能变化。对缓存敏感的 Agent 系统来说,这种变化很难排查。
两种接口的调用链差异
Completion 的路径更短:
flowchart LR
A[开发者构造 prompt] --> B[tokenizer]
B --> C[模型推理]
C --> D[生成结果]
A:::control
classDef control fill:#e8f5e9,stroke:#2e7d32,color:#111;
Chat Completion 多了一层模板:
flowchart LR
A[开发者构造 messages] --> B[服务端 Chat Template]
B --> C[actual_prompt]
C --> D[tokenizer]
D --> E[模型推理]
E --> F[生成结果]
B:::blackbox
classDef blackbox fill:#fff3e0,stroke:#ef6c00,color:#111;
多出来的模板层不是坏事,它让普通开发更简单,也让服务商可以封装工具调用、结构化输出、多模态消息等能力。问题在于:Agent 如果要精确规划缓存前缀,这层模板就成了不确定来源。
Agent 为什么更在意控制权
Agent 系统常见请求并不是短 prompt,而是包含很多重复内容的长上下文:
[系统规则]
[安全边界]
[工具列表]
[工具 JSON Schema]
[输出协议]
[Few-shot 示例]
[用户任务]
[历史计划]
[历史工具调用]
[历史工具结果]
[最新观察]
[下一步请求]
其中前六项通常非常稳定,工具 Schema 还可能特别长。如果这些内容每轮都重新计算,成本会被放大。
典型 Agent 循环如下:
sequenceDiagram
participant U as 用户
participant A as Agent 编排器
participant M as LLM
participant T as 工具系统
U->>A: 提交任务
A->>M: 固定前缀 + 当前状态
M-->>A: 生成工具调用
A->>T: 执行工具
T-->>A: 返回观察结果
A->>M: 固定前缀 + 历史状态 + 新观察
M-->>A: 生成下一步或最终答案
A-->>U: 返回结果
这个循环里,缓存命中主要依赖两个条件:
- 固定前缀尽量长。
- 固定前缀的 token 序列每次完全一致。
completion 更容易满足这两个条件,因为 prompt 的排列、序列化格式、空白字符、工具定义顺序都由开发者控制。
Completion 也不是无脑选择
completion 给了更多控制权,也把更多责任交给开发者。
| 风险 | 说明 | 应对方式 |
|---|---|---|
| 模板对齐风险 | 模型实际训练使用的聊天格式可能和手写 prompt 不一致 | 使用模型官方 Chat Template,固定版本并写测试 |
| 工具调用格式要自管 | 没有服务端自动管理 tool_calls | 自己定义工具协议,并做严格解析 |
| 安全边界更靠业务实现 | 角色隔离、工具权限、结构化消息需要自己处理 | 对工具参数做 schema 校验和权限检查 |
| 模型迁移成本更高 | 换模型时模板可能要改 | 把 prompt renderer 抽成适配层 |
| 部分服务不提供 completion | 很多新模型只开放 chat 或 responses 类接口 | 使用服务商的显式缓存能力替代 |
也就是说,completion 适合追求可控前缀和缓存命中的 Agent 框架,但不一定适合所有业务。
如果需求只是普通聊天、客服问答、短上下文生成,chat/completion 的便利性通常更重要。它能减少模板维护成本,也能直接使用服务商封装好的工具调用和结构化输出能力。
适合用哪种接口
可以按场景判断:
| 场景 | 更适合的接口 | 原因 |
|---|---|---|
| 短对话、普通问答 | chat/completion | 开发成本低,角色结构清晰 |
| 服务商工具调用能力很完善 | chat/completion | 可直接使用工具 schema、流式工具调用等能力 |
| 长上下文 Agent | completion 或带显式缓存控制的 Chat API | 稳定前缀长,缓存收益更明显 |
| 多轮工具协作 | completion | 工具调用、观察结果和状态格式可完全固定 |
| 对 prompt 可复现性要求高 | completion | actual prompt 可记录、可 diff、可测试 |
| 不想维护模型模板 | chat/completion | 服务端负责 Chat Template |
| 模型只支持 Chat API | chat/completion | 只能在服务商能力范围内优化 |
更精确的结论是:从纯效率和控制权看,completion 更适合需要显式管理上下文前缀的 Agent;从易用性和标准化看,chat/completion 更适合通用应用开发。
如何设计更容易命中缓存的 Agent Prompt
即使用 completion,也需要把 prompt 写成有利于 Prefix Caching 的结构。
稳定内容放在最前面
不要把时间戳、请求 ID、用户最新问题这类高变化内容放在开头。
不利于缓存:
Request time: 2026-06-07 10:01:33
User: ...
System rules: ...
Tools: ...
更利于缓存:
System rules: ...
Tools: ...
Output format: ...
Request time: 2026-06-07 10:01:33
User: ...
工具定义使用确定性序列化
工具 Schema 如果每次 key 顺序不同,缓存会被打断。建议使用稳定排序和固定缩进。
import json
def stable_json(obj) -> str:
return json.dumps(
obj,
ensure_ascii=False,
sort_keys=True,
separators=(",", ":")
)
同一个工具定义应始终渲染成同一个字符串。
历史状态尽量 append-only
Agent 每轮都重写历史摘要,会导致前缀变化。更缓存友好的方式是保留稳定历史,只在末尾追加新观察。
[固定前缀]
[任务初始状态]
[Step 1 tool_call]
[Step 1 observation]
[Step 2 tool_call]
[Step 2 observation]
[当前请求]
如果必须压缩历史,最好在明确的阶段做 checkpoint,并接受缓存从压缩点之后重新计算。
不依赖隐藏推理字段
如果某一轮的重要结论会影响后续行为,就把它写成显式状态:
Agent memory:
- 已确认用户需要比较 completion 和 chat/completion。
- 关键约束:重点分析 KV Cache 和 Prefix Caching。
- 工具调用结果:未发现需要外部查询。
不要指望后续请求一定能拿到上一轮模型内部推理。隐藏推理字段是否保留、是否回放,通常由服务商决定。
给 prompt renderer 写快照测试
Prompt 变化会影响缓存,也会影响模型行为。可以把最终 prompt 落盘做测试。
def test_prompt_snapshot():
prompt = build_prompt(
task_state="Step 1 done.",
latest_user_input="Continue."
)
assert prompt == load_snapshot("agent_prompt_v1.txt")
当工具定义、系统规则、模板格式被修改时,测试能立刻暴露实际 prompt 的变化。
Chat API 的显式缓存能力是在补控制权
有些服务商已经在 Chat 类接口里提供显式缓存参数,例如对某些消息块设置 cache_control,或提供 prompt cache 相关能力。
概念上类似这样:
{
"role": "system",
"content": [
{
"type": "text",
"text": "Long stable system prompt and tool definitions...",
"cache_control": {
"type": "ephemeral"
}
}
]
}
这说明 Chat API 并不是不能做缓存优化,而是需要服务商把一部分缓存控制权重新暴露出来。
在这种模式下,开发者仍然使用 messages,但可以告诉服务端哪些块是稳定的、值得缓存的。它介于两者之间:
| 方式 | 易用性 | 控制权 | 缓存可预测性 |
|---|---|---|---|
纯 completion | 中 | 高 | 高 |
纯 chat/completion | 高 | 低 | 取决于服务商模板 |
| 带显式缓存控制的 Chat API | 高 | 中 | 中到高 |
如果服务商提供稳定的 Chat Template、明确的缓存块语义、可观测的 cache hit 指标,那么 Chat API 也可以支撑复杂 Agent。否则,缓存问题会更像一个黑盒。
核心取舍:便利性换控制权
chat/completion 是对底层文本续写的一层抽象。它把角色、消息、工具调用包装成更好用的结构,降低了普通应用的开发成本;代价是开发者无法完全控制最终 prompt。
completion 更接近模型的真实输入形式。它要求开发者自己维护模板和协议,但也让稳定前缀、工具定义、历史状态的排列方式完全可控。对长上下文、多轮工具协作、需要降低重复推理成本的 Agent 来说,这种控制权会直接影响 KV Cache 复用效果。
一个实用判断标准是:
如果重点是快速接入和少维护模板,优先用 chat/completion。
如果重点是长上下文 Agent 的成本、延迟和可复现性,优先考虑 completion 或具备显式缓存控制的 Chat API。
真正影响模型成本的不是接口名字,而是最终 token 序列是否稳定、长前缀是否能复用、状态是否由开发者显式管理。