芥末
发布于 2025-11-27 / 0 阅读
0
0

Agent 开发中 Completion 接口与 Chat Completion 的 KV 缓存取舍

Agent(智能体)开发和普通聊天机器人不太一样。普通聊天机器人往往是“一问一答”,而 Agent 通常会经历多轮推理、工具调用、工具结果回填、状态更新,再继续生成下一步动作。

这类系统的一个隐藏成本来自上下文重复计算:每一轮请求都会带上系统提示词、工具定义、历史消息、工具返回结果等大量内容。大语言模型 LLM(Large Language Model)在推理时,如果能复用前面相同 token(词元)序列的 KV Cache(Key-Value Cache,键值缓存),就可以少算一大段上下文,从而降低延迟和推理成本。

从这个角度看,completionchat/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]

completionchat/completion 的核心区别在于:

接口开发者提交什么模型实际消费什么谁控制最终 prompt
completion一个完整字符串 prompt由这个字符串分词得到的 token 序列开发者
chat/completionmessages 数组服务端套模板后生成的 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 消息里可能包含:

  • content
  • reasoning_content
  • tool_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: 返回结果

这个循环里,缓存命中主要依赖两个条件:

  1. 固定前缀尽量长。
  2. 固定前缀的 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、流式工具调用等能力
长上下文 Agentcompletion 或带显式缓存控制的 Chat API稳定前缀长,缓存收益更明显
多轮工具协作completion工具调用、观察结果和状态格式可完全固定
对 prompt 可复现性要求高completionactual prompt 可记录、可 diff、可测试
不想维护模型模板chat/completion服务端负责 Chat Template
模型只支持 Chat APIchat/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 序列是否稳定、长前缀是否能复用、状态是否由开发者显式管理。


评论