第一轮,你告诉客服助手:“我的订单号是 A100。”它回答“请问需要什么帮助?”第二轮,你问“这个订单能退吗”,它却重新索要订单号。聊天窗口里明明保留着第一句话,为什么模型没看见?
先别急着换一个“记忆更好”的模型。最值得检查的是第二次请求。聊天窗口保存的内容、应用发送的内容、模型实际可用的上下文,是三件不同的事。
每次回答,都有一份实际输入
最简单的聊天接口每次只处理收到的消息。如果第二次请求只包含“这个订单能退吗”,模型无法从这几个字恢复 A100。界面显示历史,只说明前端或数据库存了历史,不说明后端已经把它放入请求。
要让第二轮有依据,应用需要把相关历史与当前问题一起交给模型。另一种接口允许引用服务端保存的会话或上一条响应,历史的装配由服务端负责。两种方式的使用体验可以很像,但状态存放的位置不同。无论谁负责装配,都应该能解释当前回答依据了哪些消息。
下面用 Python 构造两份请求。它不调用模型,目的是让输入差异直接可见。JSON 的缩进只是便于阅读,并不表示具体厂商的请求协议。
import json
# 应用保存的历史:用户给出了订单号,助手随后询问需求。
# 生产环境应从当前用户的会话记录读取,不能混入其他会话。
history = [
{"role": "user", "content": "我的订单号是 A100。"},
{"role": "assistant", "content": "请问需要什么帮助?"},
]
current = {"role": "user", "content": "这个订单能退吗?"}
# 第一份输入没有历史,因此缺少“这个订单”指向哪个订单的信息。
without_history = [current]
# 第二份输入显式带上历史;复制列表避免修改应用已经保存的数据。
# 带上订单号只解决指代问题,是否能退仍需要订单状态和退货政策。
with_history = [*history, current]
for label, messages in [("未带历史", without_history), ("带上历史", with_history)]:
print(label)
print(json.dumps(messages, ensure_ascii=False, indent=2))
第二份输入仍不能凭空证明 A100 可退。历史提供的是用户声称的订单号,后台查到的订单状态才是业务依据。把这两者分开,才能避免把“模型记住了”误当成“模型已经核实了”。
多轮消息还要保留必要的结构。一次工具调用的请求与结果是一对关系,如果只保留结果而丢掉调用,或者把另一个调用的结果接过来,模型可能无法解释这段内容,有些 API 还会直接拒绝请求。裁剪历史应以完整对话轮次或完整工具交互为单位,而不只是删掉最前面若干行。
为什么不能把所有历史一直带上
模型上下文窗口限制了单次可处理的序列长度。系统要求、历史消息、工具定义、检索资料、当前问题和输出预算都会消耗空间;某些接口对推理 Token 还有单独的计量与限制,需要按目标模型说明计算。
设一个教学模型窗口为 8,000 Token,计划最多生成 1,000 Token,再留 300 Token 余量,那么输入就不能随意超过 6,700 Token。这些数字只是预算演示。真实计数要使用模型对应的 Tokenizer 或服务端计数接口,消息模板也会带来开销。
超限可能直接报错,也可能由某层应用裁剪。即使没有超限,输入里的信息仍可能没被正确利用:关键条件埋得太深、无关材料过多、相互矛盾的历史没有处理,都会影响回答。窗口容量表示“最多装得下多少”,不能直接当作“每个位置的信息都能可靠使用多少”。
因此,历史管理其实是在选择本轮需要的信息。最近两轮对话通常有助于理解指代,订单状态适合通过工具重新取得,长篇闲聊不一定要全部保留。把所有东西压成一段摘要虽然省空间,却可能丢失“用户尚未确认”“金额只是估算”之类限定词。
更稳妥的做法是保留摘要的用途与来源:摘要帮助恢复主题,重要事实仍能回到原消息或业务记录核验。对“用户已同意付款”这样会影响动作的事实,不能仅凭一个模型生成的摘要就执行。压缩改变了输入,不只是改变了存储格式。
长上下文、摘要和外部记忆分别保留什么
假设对话已有一百轮,用户第十轮说地址是北京,第八十轮改成上海,第九十九轮要求修改 A100 的收货地址。把全部历史装进窗口,至少保留了原始关系;只保留最近两轮,可能缺少地址;摘要若写成“用户提到北京和上海”,又丢掉了更正顺序。
可以把本轮输入组织为三个部分:少量近期原始对话用于指代,经过来源标记的摘要用于恢复主题,后台查询结果用于提供当前业务状态。例如摘要写“用户在第八十轮把意向地址改为上海,尚未提交”,工具结果写“A100 当前登记地址仍是北京”。两者并不矛盾,一个是意向,一个是已保存事实。
这类区分不能简单交给窗口长度。长上下文增加容纳空间,摘要改变表示,外部记忆把信息存放在应用中,RAG 根据当前问题挑选资料。它们可以组合:完整历史存数据库,本轮只取相关记录和最近几轮,再生成回答。
如果历史里已经出现完整工具交互,也要成组保留。例如助手请求查询 A100,工具用调用 ID 返回订单详情;删掉请求却留下结果,可能使 API 无法关联,也使模型失去“为什么读取这份数据”的背景。按字符剪掉前半段通常不如按消息和业务单元选择。
长输入还有利用质量问题。把唯一有效条款埋在大量近似但过期的规则中,即使总长度未超限,模型也可能选择错误条件。这里要改善资料选择、版本标记和相关性,而不是继续放大窗口。输出预算同样要预留:输入恰好装满后,没有空间完成回答也不算成功。
输入齐了,模型为什么还要等一会儿才开始说话
一次自回归生成通常可以分成 Prefill 和 Decode。Prefill 处理已有输入,为各层计算上下文表示以及后面要用的 Key、Value;它能并行处理多个输入位置,但因果掩码仍限制每个位置可见的范围。得到最后位置的输出后,就能选择第一个生成 Token。
Decode 处理后续生成步骤。上一步选出的 Token 成为新的输入,模型计算它在各层的表示,预测再下一个 Token。这种前后依赖决定了生成一百个 Token 通常不是一次独立矩阵运算就能完成的。
所以“首字快”和“整段快”是不同体验。很长的输入可能让首个输出等待较久;较短的输入也可能因为输出很长而迟迟无法结束。实际首 Token 延迟还包括网络、排队、调度与服务端其他工作,不能把所有等待都归到 Prefill 上。
这里会产生一个明显的重复:第十步生成时,前九步里的旧位置是否还要重新计算?对因果注意力来说,旧位置看不到后来的 Token,只要原有前缀与计算配置不变,它们的 K、V 不会因为新增尾部 Token 而改变。于是可以把这些结果缓存下来。
KV Cache 保存了什么,又没有保存什么
KV Cache 在每个注意力层保存已经处理过的位置的 Key 和 Value。新位置的 Query 仍要与可见历史的 Key 匹配,再汇总对应 Value,但无需为了这些旧位置反复运行同样的投影和前面的计算。缓存并没有省掉“关注历史”,而是省掉了大量重复计算。Transformers 缓存说明给出了逐层追加 K、V 的过程。
用一个三步过程理解更直接。输入 A、B、C 后,Prefill 得到这三个位置各层的缓存,并预测 D。把 D 送入 Decode,算出 D 的 K、V,追加到缓存,预测 E。下一步只送入尚未处理的 E,而不是再次把 A 到 E 全部当作新输入追加。自己写生成循环时,重复送入已缓存位置会造成位置和掩码不匹配。
再给缓存加上形状。假设一个注意力层有 2 个 KV 头,每头 4 维,批大小为 1,那么三个输入位置的 K、V 各是 [1,2,3,4]。处理 D 后,各自变成 [1,2,4,4];增长的是位置轴,头数和每头维度不变。每个注意力层都保存自己计算出来的 K/V,不能只缓存第一层再拿给所有层使用。
为什么通常不把所有旧 Query 也留着?因为下一次预测需要新位置的 Query,用它查询所有可见 K、V。旧位置的注意力输出已经参与了当时的计算,下一步不需要重新回答旧位置的问题。这里讨论的是常见自回归推理,不意味着所有架构只有这一种缓存设计。
缓存的代价是显存。对普通多头注意力,粗略字节数可以写成:
2 × 层数 × 批大小 × 序列长度 × KV 头数 × 每头维度 × 每元素字节数
开头的 2 表示 K 和 V。若有 32 层、批大小 1、长度 4,096、32 个 KV 头、每头 128 维、每元素 2 字节,结果约为 2 GiB。这个估算只算 KV,不算模型权重、激活、分配碎片和其他运行内存。分组查询注意力减少 KV 头数,也就能改变缓存开销。
这里有一个容易误会的地方:KV Cache 不是用户长期记忆。服务可能在请求结束后回收它,也可能为了相同前缀复用它;无论哪种,都不能替代应用保存用户的会话。更换模型、修改前缀或位置处理方式后,旧缓存也不能随便继续用。
还要区分三种常被叫成“缓存”的东西。KV Cache 保存内部计算结果;Prompt 前缀缓存是服务对可复用前缀计算的管理机制;回答缓存直接复用最终答案。最后一种会跳过本次生成,所以订单状态、权限或政策版本变化时,必须重新判断答案是否仍适用。
还可以用计算量理解缓存的收益。若每生成一步都重新运行整个前缀,长度 t 的完整注意力会处理约 t² 对位置关系。使用缓存后,新位置只需要与 t 个可见位置匹配,这一部分随历史长度线性增长。模型的其他投影、FFN 和内存访问仍然要花时间,所以不能把这个量级差直接换算成实际加速倍数。
这也解释了长对话为什么即使命中缓存,后续生成仍可能变慢。需要读取的 K、V 越来越多,显存带宽和容量都可能成为影响因素。缓存减少重复工作,不意味着长上下文从此没有成本。
如果历史前半段被修改,后面位置的表示也可能改变。例如把系统规则从“回答中文”改为“回答英文”,即使用户问题没变,也不能假设整个旧缓存仍然适用。复用通常依赖相同前缀和兼容的模型配置,不能只比较最后一句问题。
应用还应区分“历史保留”与“历史有效”。用户先说地址在北京,后来明确更正为上海,两个消息都在上下文里并不表示两个地址同时有效。一个好的上下文装配过程需要保留更正关系,或者把已确认的当前状态与原始历史分开提供。摘要如果只摘出两个地名,反而会制造冲突。
排查时可以保存一份脱敏后的实际请求快照,标出哪些内容来自当前输入、历史摘要、工具结果和检索片段。这样发现订单号缺失时,能定位到装配阶段;订单号存在却被错误使用时,才去看提示与模型行为。这份快照应遵守数据保存规则,它的目的在于复现问题,不是无限保留所有用户内容。
沿真实模型调用观察缓存增长
抽象的 A、B、C 可以换成模型真正接受的张量。下面加载一个小型聊天模型,先处理完整输入,再只输入一个新 Token。它执行真实本地推理,首次运行需要下载权重;生成文本以运行结果为准。模型选择只为观察机制,不作为售后能力推荐。
下载:cache_walkthrough.py下载。为了让读取过程直观,示例用 CPU 和浮点 32 位,不依赖自动设备分配。运行较慢时先缩短生成长度,不能把单机教学程序的耗时当作线上服务性能。
import torch
from transformers import AutoModelForCausalLM, AutoTokenizer
model_id = "Qwen/Qwen2.5-0.5B-Instruct"
tokenizer = AutoTokenizer.from_pretrained(model_id)
model = AutoModelForCausalLM.from_pretrained(
model_id, torch_dtype=torch.float32
).eval()
messages = [
{"role": "system", "content": "根据给定资料回答;缺少依据时说明无法判断。"},
{"role": "user", "content": "我的订单号是 A100,请复述这个订单号。"},
]
inputs = tokenizer.apply_chat_template(
messages, tokenize=True, add_generation_prompt=True,
return_dict=True, return_tensors="pt",
)
with torch.inference_mode():
# Prefill:完整输入只处理一次,返回每层缓存和所有输入位置的 logits。
first = model(**inputs, use_cache=True)
cache = first.past_key_values
next_id = first.logits[:, -1, :].argmax(dim=-1, keepdim=True)
print("Prefill 后缓存位置数:", cache.get_seq_length())
# 此时 next_id 只是选出来了,还未送入模型,所以缓存不包含它。
generated = [next_id.item()]
mask = inputs["attention_mask"]
eos = model.generation_config.eos_token_id
stop_ids = set(eos if isinstance(eos, list) else [eos])
# 最多生成 16 个 Token。每轮只提交一个未处理的新 ID;旧前缀由缓存提供。
for _ in range(15):
if generated[-1] in stop_ids:
break
mask = torch.cat([mask, mask.new_ones((1, 1))], dim=-1)
step = model(input_ids=next_id, attention_mask=mask,
past_key_values=cache, use_cache=True)
cache = step.past_key_values
next_id = step.logits[:, -1, :].argmax(dim=-1, keepdim=True)
generated.append(next_id.item())
print("追加处理一个位置后:", cache.get_seq_length())
# 对新生成 ID 整体解码,不把原始提示一起当作回答打印。
print("生成文本:", tokenizer.decode(generated, skip_special_tokens=True))
print("结束类型:", "结束标记" if generated[-1] in stop_ids else "达到教学长度上限")
这个循环有个容易忽略的时间差:选出 D 后,缓存仍只包含输入;把 D 送入模型,缓存才增加 D,并产生 E 的分数。因此生成 N 个 Token 时,若最后一个只被选出而没继续送入,缓存通常不包括这个最后 Token。缓存长度不能机械等同于“用户已经看到的全部 Token 数”。
mask 的长度也随缓存加一,因为新 Query 可以读取完整有效历史。示例限制为单条、无 padding、连续位置输入,让模型按已缓存长度推导位置;自己加入批次填充、滑动窗口或截断后,需要同步维护位置与缓存规则。不能只改一个列表就假定所有模型都支持。
Transformers 的缓存文档说明了新输入、历史缓存和 attention mask 的配合。通常使用 generate 更省事,手写循环的价值在于观察边界,不是取代成熟的生成实现。
为什么减少计算量,不等于按同样倍数减少等待
普通稠密注意力在长度 N 的 Prefill 中,需要约 N² 个位置对的匹配。把输入从 1000 增加到 2000,注意力分数部分的工作量约变成四倍;这不代表整次请求耗时严格四倍,因为投影、FFN、内存传输和调度还有不同开销。
带缓存的单步 Decode 只有一个新 Query,要读 N 个历史 Key 和 Value。这一注意力部分随 N 近似线性增长,但还要读模型权重、计算 FFN,并把新 K/V 写回缓存。小批次 Decode 常受到内存带宽影响,不能只用 GPU 峰值算力推算速度。
批量处理让多个请求共享一次权重读取和较大的矩阵运算,有机会提高总体吞吐,但排队等待可能增加单用户延迟。所谓“每秒处理更多 Token”和“这一位用户更早拿到答案”不是同一个优化目标,第六篇会把两种口径放进具体时间线。
前缀缓存还要求前缀真的相同。例如一千个用户共享同一段系统政策,如果每次把当前时间写在第一行,时间后的许多位置可能失去公共前缀复用机会。把稳定内容放前面有助于形成复用条件,但实际命中粒度、保存时长和费用仍由服务实现决定。
流式返回只是怎样把结果送出来
模型逐步生成并不要求界面等到结尾才显示。服务可以把生成中的文本片段作为事件发送,应用收到后追加显示,这就是流式返回的常见效果。但一个事件不保证对应一个 Token:服务可能合并多个 Token,也可能发送角色、工具参数片段、结束原因等非正文事件。
如果模型输出的是 JSON,前半段往往不是合法 JSON。例如刚收到一个左花括号时,程序还不能把它当成完整业务对象。界面可以显示进度,业务操作应等输出完整并通过校验后再执行。连接中断也不能自动视为“模型已经回答完”。
这解释了为什么使用流式返回能够改善等待感受,却不必然缩短总推理时间。首段文字来得早,用户开始阅读得早;完整答案何时可用,仍取决于剩余生成、传输和处理。对工具动作尤其要分清“看见参数的一部分”和“收到一个完整有效调用”。
流式交付还需要区分三个结束:模型停止生成、服务正常发完、客户端成功保存。模型已经完成但网络断开,用户可能没收到尾部;客户端按取消键,也未必自动停止服务端计算。应用应保留完成或中断状态,而不能把连接关闭统一标为成功。
如果正文片段先后是 {"order_、id":"A100"、},应该先按同一次响应拼接,再解析完整 JSON。工具参数可能同时按调用 ID 分片,要分别缓存,不能把两个工具调用的片段串成一个对象。完整参数通过校验后,才进入工具执行阶段。
SSE 常用于服务端向客户端持续推送事件,WebSocket 支持双向消息,WebRTC 常用于实时媒体。传输协议改变送达与交互方式,不改变模型逐步生成的事实。选用 WebSocket 不会自动解决“这段话是否已经说完”。
配套的 stream_chat.py下载使用本地 TextIteratorStreamer 展示文本增量,不需要商业 API。工作线程负责生成,主线程消费片段,错误与超时分别记录;它是本地流式例子,不包含 HTTP/SSE 服务。遇到长度上限仍标明截断,避免把最长输出当作正常结束。
把这些关系放在一起,排查“忘记”就有了顺序:先看请求有没有相关历史,再看预算和裁剪,再看资料是否清楚,最后检查模型是否正确使用信息。缓存主要回答计算效率问题,流式主要回答交付方式问题。它们都不能补回一条从未进入上下文的事实。
当资料从几条消息增长成上千份文档,“选择本轮需要的信息”会变成独立问题,我们会在 RAG 中继续。下一篇把同一条生成链扩展到图片、声音与实时对话。服务接入、事件处理和缓存细节放在配套指南下载。