LLM是怎么处理messages数组的:提示词缓存又是什么


背景
大模型本质上执行的是文字接龙任务——给定一段文字后,模型会根据概率预测下一个字。严格来说,模型仅识别token,输入一串token后,它预测下一个token。当我们调用LLM接口时,传递给模型的messages参数如何转化为token ID,这中间涉及哪些流程?理解这一过程,就能明白什么是提示词缓存,以及它解决了什么问题。
什么是上下文
大模型自身不具备记忆能力,每次调用都相互独立。这意味着,为了获取准确回复,每一轮对话都必须附带完整的上下文信息。我们在调用大模型接口时传递的 messages 参数,就承载了完整的上下文。因此,所谓的上下文管理,本质上就是针对messages进行管理。
messages 通常由三部分组成:
- system:系统角色设定。
- 每一轮的用户提问。
- 模型上一轮的回答。
模型正是依据“用户问过什么 + 自己此前答过什么”这一线索,来延续对话。
如果不带历史回复会怎样?
请看一个示例:
第一轮
- 你:帮我写一个 Python 排序代码。
- 模型:提供了一段完整代码。
第二轮(仅发送新问题,未附带模型之前的代码)
- 你:帮我把这段代码改成降序。
在此场景中,由于我们的messages数组没有包含模型刚才的回答(即那段代码),模型实际上只接收到两个问题:
- 帮我写 Python 排序代码。
- 帮我把这段代码改成降序。
模型并不知道它之前提供过哪段代码,也不清楚“这段代码”指的是什么,因此只能进行猜测或重新编写。
由此可见,不携带历史回复或对话信息,会引发一系列问题:
- 上下文断联:无法记住刚刚聊过的内容。
- 重复回答:反复从头开始,无法接续话题。
- 逻辑混乱:无法衔接上一轮的结论。
- 改写、续写或迭代需求彻底失效。
所以,与大模型对话时必须发送完整的上下文信息。
大模型接口收到 messages 后做了什么
LLM 接口接收到 messages 后,大致会经历以下步骤:获取 messages → 按照固定格式拼接成一段长文本 → 切分成 Token → 进入 Prefill 阶段计算 KV。
第一步:理解核心前提
大模型根本不理解 JSON 数组,它只能读取一串连续的token。
我们传递给大模型的JSON数据格式如下:
复制代码[
{ "role": "system", "content": "你是专业程序员" },
{ "role": "user", "content": "解释一下KV缓存" },
{ "role": "assistant", "content": "KV缓存就是..." }
]
但在模型看来,这只是一堆“角色+内容”的组合。接口后台的首要任务,就是将这个数组压缩变形,拼接成一段符合模型规则的连续文本。
第二步:如何拼接成文本?
每个大模型都拥有固定的角色分隔模板,拼接过程并非随意进行。
通用的拼接逻辑是:为每个 role 添加专属的标记头和尾,从而串联起整个对话:
- 首先写入标记:系统角色开始 + 系统内容。
- 接着写入标记:用户开始 + 用户提问。
- 然后写入标记:助手开始 + 助手回答。
- 最后停留在:助手开始,等待模型继续生成。
举个例子,原始 messages:
- system:你是专业程序员。
- user:解释一下KV缓存。
- assistant:KV缓存就是存注意力中间数据。
后台自动拼接成的完整长文本大致如下(模拟格式):
复制代码<|system|>
你是专业程序员
<|user|>
解释一下KV缓存
<|assistant|>
最终停在 <|assistant|> 之后,意味着模型应该在此处开始生成回答。
那些 <|system|> <|user|> <|assistant|> 均为特殊占位符 Token,并非普通文字,模型依靠它们来区分说话者。
第三步:如何计算 Token?
分词(Tokenize)
将拼接好的整段长字符串,根据模型自带的词表字典进行切割:
- 常见整词:
缓存→ 1个 Token。 - 生僻长词、英文、字母:拆分成多个 Token。
- 角色标记
<|system|>→ 单独 1 个特殊 Token。
最终结果
一整段对话加上角色标记,全部被切割成一长串数字 ID 列表。文字 → 被切碎成 Token → 每个 Token 对应一个数字 ID。大模型不识别文字,只认识这串数字 ID。
我们常说的“消耗多少输入 token”或“上下文窗口 4k/8k”,指的就是这一整段内容拼接、切割完成后的总 Token 数量。
第四步:Token 如何进入模型计算?
- 将这串 Token 数字送入模型的 Prefill 预填充阶段。
- 一次性并行计算所有 Token 的注意力,生成全套 KV 缓存。
- 获得 KV 状态后,开始逐字 Decode,逐个输出回答。
完整流程串联
- 我们在前端构建
messages对话数组。 - 接口后台按照角色模板将数组拼接成带有特殊标记的长文本。
- 对长文本进行 Token 分词,转换为数字 ID 序列。
- 统计 Token 数量(用于计费和判断是否超长)。
- 送入模型 Prefill,计算并生成 KV 缓存。
- 模型基于完整上下文,逐字生成回复。
大模型如何生成文本
如前所述,LLM 的核心功能就是根据当前输入预测下一个字或词。那么,LLM 接口为何能按照 JSON 格式返回回复、工具调用等指令呢?
实际上,从生成、续写到多轮对话的完整过程,模型始终严格遵循一套固定的文本拼接规则,这与 messages 数组转文本的规则相同。
无论我们发送的 messages,还是模型正在逐字生成的回复,底层都遵循相同的角色模板拼接规则:使用 <|系统|>、<|用户|>、<|助手|> 这类特殊标记与内容进行拼接。
模型并非随意组合文字,而是按照固定格式拼成一条结构化长文本,然后再预测下一个字。这些特殊标记和结构化文本都经过了训练
模型如何回复
我们传递的 messages 会被拼接成:
复制代码<|system|>
你是智能助手
<|user|>
世界最高峰是
<|assistant|>
模型停留在 <|assistant|> 之后,准备从此处开始续写。
模型逐字生成时,每生成一个字,就自动拼接到整个结构化文本的末尾:
第一步生成「珠」→ 全局文本变为:
复制代码<|system|>
你是智能助手
<|user|>
世界最高峰是
<|assistant|>
珠
第二步再生成「穆」→ 自动继续向后拼接:
复制代码<|system|>
你是智能助手
<|user|>
世界最高峰是
<|assistant|>
珠穆
每生成一个 token,都按照这套格式,追加到整段结构化文本的最后。
模型如何生成调用工具的指令
普通对话使用<|system|> <|user|> <|assistant|>等特殊标记进行拼接。
而工具调用则会多出两个角色标签:
<|function_call|>:表示助手要调用工具。<|function_result|>:表示工具返回结果给模型。
工具调用完整拼接过程
- System 固定:需要告知模型可调用的工具、参数格式、技能列表以及入参规则等。
- 用户提问:
<|user|> 今天北京天气怎么样? - 模型判断要调工具:模型不会直接回答,而是在
<|assistant|]后面,按格式生成工具调用结构:
复制代码<|assistant|>
<|function_call|>
{"name":"查天气","parameters":{"城市":"北京"}}
底层原理仍是普通文本续写,只是续写的内容是 JSON 格式的工具调用指令。
- 后端拦截这个 function_call:接口后端识别到模型输出了工具调用指令后,不再继续生成回答,而是去执行相应函数,并获取结果:
北京:晴,25℃ - 把工具结果按规则拼回去:后端构建一条工具结果消息,并将其塞入 messages,按照模板进行拼接:
复制代码<|function_result|>
北京:晴,25℃
6. 模型再接着续写正常回答:模型在接收到 <|function_result|> 结果后,继续在其后面生成自然语言回复:
复制代码<|assistant|>
北京今天天气晴朗,气温25度,适合出门。
整体拼接后的完整长文本
复制代码<|system|>
你有查天气、计算器等工具,严格按JSON格式调用
<|user|>
今天北京天气怎么样?
<|assistant|>
<|function_call|>
{"name":"查天气","parameters":{"城市":"北京"}}
<|function_result|>
北京:晴,25℃
<|assistant|>
北京今天天气晴朗,气温25度,适合出门。
整个流程只用了一套拼接逻辑,只是额外增加了两个标签。
和普通对话的区别
- 普通对话:仅循环 user ↔ assistant。
- 工具调用:多了一轮闭环
user → assistant(发工具调用) → function_result → assistant(给答案)。
所有工具调用、Skill 调用、插件调用,底层并无特殊魔法,依然是模板拼接 + Token 预测 + KV 缓存。模型只是“按格式续写一段工具调用文本”,后端识别后执行,再将结果拼回上下文。
为什么必须按规则拼接?
- 模型通过训练学会了使用格式标记来区分说话者:
<|user|>后面是用户说的,<|assistant|]后面是模型要说的。 - 模型续写时只在
<|assistant|]标签后面接字,不会跑到用户位置或系统位置去生成。 - 如果整套格式混乱,模型就会插话、逻辑偏差、答非所问。
什么是提示词缓存
Prompt Caching(提示词缓存/前缀缓存) 是大模型推理的核心优化技术:它将请求中重复的前缀(静态内容)缓存下来,当下次请求的前缀完全相同时,直接复用缓存,无需再次计算,从而降低延迟、节省成本、减少算力消耗。
核心原理
大模型推理分为两个阶段:
- Prefill(预填充):将提示词转换为 Token,并计算注意力 KV 状态。此阶段计算量最大、成本最高。
- Decode(解码):在已有上下文基础上逐字生成回复,计算成本较低。
Prompt Caching 的做法是:将 Prefill 阶段计算出的 KV Cache 跨请求暂存;当新请求的前缀完全一致(Token 级精确匹配)时,直接复用缓存,跳过 Prefill 阶段,仅计算新增部分。
能带来什么好处
- 延迟大幅降低:在长提示词场景下,响应时间可从秒级降至百毫秒级。
- 成本显著下降:缓存命中的输入 Token 计费通常是原价的 1/10~1/4(如 OpenAI/Anthropic 新模型)。
- 节省算力:避免了重复的 Transformer 注意力计算,大幅降低 GPU 占用。
生效规则
- 前缀必须精确匹配:任何 Token 差异(如空格、标点、大小写)都会导致缓存失效。
- 长度门槛:例如 OpenAI 要求前缀 ≥ 1024 Token,并按 128 Token 的增量进行对齐。
- 缓存时效:通常 5~10 分钟无访问后自动过期,在低峰期最长可维持约 1 小时。
- 最佳写法:将静态内容(系统指令、角色设定、示例)放在前面,动态内容(用户输入、变量)放在后面。
典型适用场景
- 多轮对话(上下文复用)。
- 固定系统指令 + 动态用户问题。
- 反复处理同一份长文档或知识库。
- 高频模板化查询(如客服、FAQ、RAG)。
一句话总结:Prompt Caching 的本质就是“相同的前缀只计算一次”,使长提示词请求既快速又经济。
Prompt Caching 的缓存前缀是什么
Prompt Caching 的缓存前缀,实际上就是我们传入的整个 messages 数组,按照模型的固定模板拼接并转换为 Token 之后的那整串 Token 序列。
缓存的 key 是什么?
缓存的key可以简单理解为,将 messages 数组转换后生成的完整 Token 序列:
- 按模型模板拼接 messages → 变成一整段带有
<|system|>``<|user|>``<|assistant|>标记的长文本。 - 对这段文本进行 Tokenize → 得到一长串数字 Token ID。
- 从开头开始的前缀 Token,就是 Prompt Caching 用于匹配缓存的唯一标准。
多轮对话如何命中缓存?
Prompt Cache 并非要求整个前缀完全一致,而是指开头的一截前缀完全相同。前缀越长,能够复用的部分就越多。
我们用符号来表示:S=system,U1=用户1,A1=助手1,U2=用户2,A2=助手2,U3=用户3
第一轮
- 请求:
S+U1 - 运行完 Prefill,缓存:Token 序列
[S, U1]
第二轮
- 请求:
S+U1+A1+U2 - 其完整 Token 前缀为:
S+U1+A1+U2
模型用这个前缀从头开始匹配缓存:
- 开头先比对:
S+U1→ 与第一轮缓存完全一致。 - 命中最长公共前缀:
S+U1。 - 直接复用这一段的 KV,无需重新计算 S+U1。
- 仅需对新增的
A1+U2进行 Prefill 补算。 - 然后将更长的
S+U1+A1+U2重新存入缓存,覆盖旧的。
第三轮
- 请求:
S+U1+A1+U2+A2+U3 - 拿它从头与第二轮缓存进行前缀匹配。
- 第二轮缓存为:
S+U1+A1+U2。 - 第三轮的开头正好是:
S+U1+A1+U2,Token 完全对齐。 - 直接完整命中第二段缓存,无需重算前面大部分内容,仅补算后面的
A2+U3。
只要新请求的开头与缓存中的开头存在一长截完全一致的 Token 前缀,就能命中这一部分,无需重新计算。
缓存的前缀是可以复用,而非要求整段等长才能复用。例如,缓存存储了:1234,新请求前缀为:1234567。
虽然长度不同,但开头 1234 完全一致,因此直接复用 1234,仅需计算后面的 567。
这就是多轮对话能够每轮都复用前一轮缓存的根本原因。
总结
掌握大模型处理messages的完整流程,从数组拼接到Token序列化再到KV缓存计算,让我们能更深刻地理解提示词缓存的工作原理。在实际的Agent应用中,合理组织提示词、优化前缀结构,可以充分利用缓存机制,降低响应延迟和计算成本。