2024 年,我做了一个面向内部员工的 AI 对话助手。功能不复杂:一个聊天框,员工可以连续追问,助手能结合上下文回答。刚上线那阵子,体验非常好——回答又快又准,还能记住你前面说过的话。可用了一两周,投诉开始多起来,而且抱怨得出奇地一致。有人说:「聊到后面,它就开始答非所问了。」有人说:「我开头明明告诉它我是做财务的,聊了十几轮,它又把我当成程序员了。」还有运营同学拿着账单找我:「这个月的 API 费用,怎么比上线那周翻了快十倍?」我最初的判断是「模型不稳定」,甚至怀疑是不是厂商偷偷把模型降级了。直到我打开请求日志,看了一眼某个「聊了很久」的会话,真正发到模型的那个请求体——我整个人都不好了。那个请求里,塞着这场对话从第一句到最后一句的全部历史,密密麻麻几万字。我这才意识到,我对大模型有一个根本性的误解:我一直以为模型「自己会记得」我们聊过什么。它不会。模型是无状态的,它对「上一句」的全部记忆,只来自我每次请求时亲手塞回去的那段历史。对话越长,我塞回去的越多——于是越来越贵、越来越慢、还越来越笨。这篇文章,是我把 token、上下文窗口、对话历史管理这套东西彻底搞懂后的复盘。
问题背景:一个"越聊越贵、越聊越笨"的 AI 助手
背景:内部 AI 对话助手,一个聊天框,支持连续追问
刚上线:回答又快又准,能记住前面说过的话,体验很好
用了一两周,投诉高度一致:
- ★★ "聊到后面就答非所问了"
- ★★ "开头告诉它我是做财务的,聊十几轮又把我当程序员"
- ★★ 运营拿账单来:"API 费用比上线那周翻了快十倍"
★ 我的判断:"模型不稳定",甚至怀疑厂商偷偷降级了模型
★★ 打开请求日志,看一个"聊了很久"的会话,真正发给
模型的那个请求体 —— 整个人不好了:
- 请求里塞着这场对话【从第一句到最后一句的全部历史】
- 密密麻麻几万字
★★ 根本性误解:我以为模型"自己会记得"聊过什么。它不会。
- 模型是【无状态】的
- 它对"上一句"的全部记忆,只来自我每次请求时
【亲手塞回去】的那段历史
- 对话越长塞回去越多 -> 越贵、越慢、越笨
★ 本文要做的:把 token、上下文窗口、对话历史管理
彻底讲透。
先搞懂 token 和上下文窗口:模型的"工作记忆"有多大
# === ★ 两个最基础、却最常被略过的概念 ===
# === ★ token:模型眼里的"字",不是你眼里的字 ===
# ★ ★ 模型不是按"字符"或"单词"处理文本的,它按【token】。
# token 是一段被切碎的文本片段 —— 一个英文单词可能是
# 1 个 token,也可能被拆成 2~3 个;一个汉字,通常是
# 1~2 个 token。
# ★ ★★ 为什么你必须关心它:① 模型的【计费】按 token 算
# (输入 token + 输出 token 分别计价);② 模型的【容量】
# 也按 token 算。你发的每一个字,都在花钱、都在占容量。
# === ★ 上下文窗口:模型一次能"看见"的全部 ===
# ★ ★ 上下文窗口(context window),是模型【单次请求】
# 能处理的 token 总量上限 —— 比如 8K、128K、200K。
# ★ ★★ 关键:这个上限,是【输入 + 输出】加起来算的。
# 你的输入(system prompt + 对话历史 + 本轮问题)如果
# 占掉了 7.9K,那一个 8K 窗口的模型,留给它【生成回答】
# 的空间就只剩 0.1K —— 答案会被硬生生截断。
# === ★★ 最关键的一点:模型是无状态的 ===
# ★ ★ 这是我那次踩坑的根。HTTP 请求是无状态的,大模型的
# API 同样【完全无状态】。你这次请求,和上次请求,在
# 模型看来【没有任何关系】。
# ★ ★★ 那"多轮对话"是怎么实现的?答案有点反直觉:是
# 【你自己】在维护记忆。每一轮新提问,你都要把【之前
# 所有的对话历史】,连同新问题,一起【重新打包】发给
# 模型。模型只是【每次都把你给它的这一整包,当场读
# 一遍】,然后回答。它没有"记忆",它只有"这次你给它
# 看的东西"。
# ★ ★ "上下文窗口",所以本质是模型的【工作记忆】——
# 而且是那种【读完即忘、下次要你重新递上】的工作记忆。
# === 小结 ===
# ★ 两个最基础却最常被略过的概念。★ token 是模型眼里的
# "字"不是你眼里的字:模型不按字符或单词处理文本而按
# token,token 是被切碎的文本片段 —— 一个英文单词可能
# 1 个也可能拆成 2~3 个、一个汉字通常 1~2 个;为什么必须
# 关心它 —— 模型计费按 token 算(输入+输出分别计价)、
# 容量也按 token 算,你发的每个字都在花钱占容量。★ 上下文
# 窗口是模型一次能看见的全部:context window 是模型单次
# 请求能处理的 token 总量上限(8K/128K/200K),关键是这
# 上限是输入+输出加起来算的,输入占掉 7.9K 那 8K 窗口
# 留给生成回答的就只剩 0.1K、答案会被硬生生截断。★★ 最
# 关键一点模型是无状态的:大模型 API 完全无状态,这次
# 请求和上次在模型看来没任何关系;多轮对话是你自己在
# 维护记忆 —— 每轮新提问都要把之前所有对话历史连同新
# 问题重新打包发给模型,模型只是每次把你给的这一整包
# 当场读一遍再回答,它没有记忆只有"这次你给它看的东西",
# 上下文窗口本质是模型读完即忘、下次要你重新递上的
# 工作记忆。
# ★ 看清两件事:① 怎么数 token ② 多轮对话其实是"你在递历史"
import tiktoken
from openai import OpenAI
client = OpenAI()
# ★ ① 用 tiktoken 数 token —— 别再用"字符数"估算
def count_tokens(text: str, model: str = "gpt-4o") -> int:
encoding = tiktoken.encoding_for_model(model)
return len(encoding.encode(text))
print(count_tokens("Hello, world!")) # ★ 英文:约 4 个 token
print(count_tokens("你好,世界!")) # ★ 中文:约 6~7 个 token
# ★★ 注意:token 数 ≠ 字符数,中文尤其要实测,别拍脑袋
# ★ ② 多轮对话的真相:每一轮,你都在"重新递交全部历史"
# 模型 API 是无状态的,messages 列表就是你递上去的"记忆"
messages = [
{"role": "system", "content": "你是一个内部助手。"},
{"role": "user", "content": "我是做财务的,帮我看个报表口径问题。"},
{"role": "assistant", "content": "好的,请描述你的报表口径。"},
# ★★ 下面这条新问题要发出去时,上面三条【必须一起带上】——
# 不带,模型就完全不知道"你是做财务的"
{"role": "user", "content": "那按权责发生制应该怎么算?"},
]
resp = client.chat.completions.create(model="gpt-4o", messages=messages)
# ★★ 关键:模型回完这一轮,你要把它的回答也 append 进 messages,
# 下一轮再连着发 —— 对话越长,这个 messages 列表越大
messages.append(resp.choices[0].message)
# ★ "记忆"从来不在模型那边,它一直在你这个 messages 列表里
对话越聊越贵:成本不是线性涨,是平方级涨
# === ★ 账单翻十倍,不是错觉,是数学 ===
# === ★ 先看清:每一轮,你都在"重发"全部历史 ===
# ★ ★ 假设一轮问答,问题 + 回答各算 500 token。
# - 第 1 轮:发 500,收 500;
# - 第 2 轮:你要带上第 1 轮的全部(1000)+ 新问题
# (500)= 发 1500,收 500;
# - 第 3 轮:带上前两轮(3000)+ 新问题 = 发 3500……
# ★ ★★ 看出来了吗:第 N 轮的【输入】token,是前面所有
# 轮的总和。整场对话累计消耗的 token,随轮数【平方级
# 增长】—— 聊到第 20 轮,单次请求的输入可能已经是
# 第 1 轮的几十倍。账单翻十倍,一点都不夸张。
# === ★ 第二笔账:慢 ===
# ★ ★ 模型处理输入是要时间的,输入 token 越多,【首字
# 响应越慢】。你那个"聊到后面变慢"的投诉,根源就在
# 这:不是模型变慢了,是你每次喂给它的东西,变多了
# 几十倍。
# === ★★ 别浪费钱的第一招:用好"system 角色" ===
# ★ ★ 那些【整场对话都不变】的指令(助手的人设、回答
# 规则、格式要求),只放在【一条 system 消息】里,
# 放在 messages 最前面。不要在每一轮 user 消息里都
# 把这些指令重复抄一遍 —— 抄一遍,就为它多付一遍钱。
# === ★ 别浪费钱的第二招:看看你的模型支不支持"提示缓存" ===
# ★ ★ 主流厂商现在大多支持【prompt caching(提示缓存)】:
# 如果你每次请求的【开头一大段】是完全相同的(比如那条
# 长长的 system prompt + 不变的工具定义),模型可以
# 把这部分的计算结果【缓存】下来,后续请求命中缓存的
# 部分,价格大幅打折。
# ★ ★★ 这条对工程结构有个要求:把【不变的内容尽量往前
# 放、放在一起】,把【每轮都变的内容放在后面】。结构
# 稳定,缓存命中率才高。这是几乎免费的一大笔省钱。
# === 小结 ===
# ★ 账单翻十倍不是错觉是数学。★ 先看清每一轮你都在重发
# 全部历史:假设一轮问答问题+回答各 500 token,第 1 轮
# 发 500、第 2 轮要带上第 1 轮全部 1000+新问题 500=发
# 1500、第 3 轮带前两轮 3000+新问题=发 3500……第 N 轮的
# 输入 token 是前面所有轮的总和,整场对话累计消耗随轮数
# 平方级增长,聊到第 20 轮单次输入可能已是第 1 轮几十倍。
# ★ 第二笔账是慢:模型处理输入要时间输入 token 越多首字
# 响应越慢,"聊到后面变慢"的投诉根源就在这 —— 不是模型
# 变慢是你每次喂的东西多了几十倍。★★ 别浪费钱第一招用好
# system 角色:整场对话都不变的指令(人设、规则、格式)
# 只放一条 system 消息放最前面,别在每轮 user 消息里重复
# 抄、抄一遍就多付一遍钱。★ 别浪费钱第二招看模型支不支持
# 提示缓存:主流厂商大多支持 prompt caching,每次请求开头
# 一大段完全相同(长 system prompt+不变的工具定义)模型
# 可缓存这部分计算结果、后续命中缓存大幅打折;这要求把
# 不变的内容尽量往前放放一起、每轮都变的放后面,结构稳定
# 缓存命中率才高,是几乎免费的一大笔省钱。
# ★ 省钱的结构:不变的放前面(可缓存),每轮变的放后面
from openai import OpenAI
client = OpenAI()
# ────────── ✗ 浪费版:指令在每轮 user 消息里重复抄 ──────────
def build_messages_bad(history: list, new_q: str) -> list:
msgs = []
for turn in history:
# ★✗ 每一轮都把这段长指令重抄一遍 —— 每抄一次多付一次钱
msgs.append({"role": "user", "content":
"你是内部助手,回答要简洁、引用制度原文……" + turn["q"]})
msgs.append({"role": "assistant", "content": turn["a"]})
msgs.append({"role": "user", "content":
"你是内部助手,回答要简洁、引用制度原文……" + new_q})
return msgs
# ────────── ✓ 省钱版:指令收进唯一一条 system,放最前 ──────────
SYSTEM_PROMPT = (
"你是公司内部助手。回答要简洁,涉及制度时引用原文,"
"不确定时明确说不确定。" # ★ 整场对话不变 -> 只说一次
)
def build_messages_good(history: list, new_q: str) -> list:
# ★★ 不变的 system 放在 messages 最前面 ——
# 既不重复付费,又能被 prompt caching 命中、大幅打折
msgs = [{"role": "system", "content": SYSTEM_PROMPT}]
for turn in history: # ★ 中间是历史
msgs.append({"role": "user", "content": turn["q"]})
msgs.append({"role": "assistant", "content": turn["a"]})
msgs.append({"role": "user", "content": new_q}) # ★ 每轮变的放最后
return msgs
# ★ 发请求前,先估一下这一包多少 token、心里有数
def estimate_cost(messages: list) -> int:
import tiktoken
enc = tiktoken.encoding_for_model("gpt-4o")
total = sum(len(enc.encode(m["content"])) for m in messages
if isinstance(m.get("content"), str))
return total # ★ 这个数随轮数平方级涨 —— 必须盯着它
lost in the middle:把上下文塞满,不等于模型都看到了
# === ★ "越聊越笨"的真相:不只是没塞够,更是塞太多 ===
# === ★ 一个反直觉的现象:lost in the middle ===
# ★ ★ 直觉会以为:只要没超过上下文窗口,我塞进去的
# 每一个字,模型都会同等认真地读。【错。】
# ★ ★★ 研究和实践都反复证实一个现象,叫【lost in the
# middle(中间迷失)】:当上下文很长时,模型对【开头】
# 和【结尾】的内容,注意力最强、记得最牢;而对【正中间】
# 的那一大段,注意力会明显【衰减】—— 经常就像没看见。
# ★ ★ 这就解释了开头那个 bug:"我是做财务的"这句话,
# 在一场长对话里,被挤到了历史的【正中间】。模型不是
# "忘了",是它在那一大坨上下文里,根本没认真"看"那一句。
# === ★ 推论一:上下文不是"装得下就行" ===
# ★ ★ 一个 128K 窗口的模型,不代表你把它塞到 127K,它
# 还能用得一样好。塞得越满,中间被"迷失"的内容就越多,
# 有效信息的"浓度"就越低。窗口大小是【物理上限】,
# 不是【推荐用量】。
# === ★★ 推论二:位置,是一种你能控制的"权重" ===
# ★ ★ 既然模型偏爱开头和结尾,那"把什么放在开头/结尾"
# 就成了一个你能主动利用的工具:
# - ★ 最重要、最不能被忽略的指令(人设、铁律)-> 放在
# 最【开头】的 system 里。
# - ★ 跟"本轮问题"最相关的关键信息(检索到的资料、
# 用户刚强调的约束)-> 尽量【靠近结尾】、靠近用户
# 当前那句问题。
# - ★ 不那么关键的、纯背景性的早期闲聊 -> 才是放中间、
# 甚至该被裁掉的部分。
# === ★ 推论三:别什么都往上下文里堆 ===
# ★ ★ 看到模型答不好,第一反应往往是"再多给它点信息"。
# 但 lost in the middle 告诉我们:无脑加料,可能让
# 关键信息的浓度【更低】,反而更差。
# ★ ★ 正确的方向不是"塞更多",而是"塞得更准"——
# 只把【这一轮真正用得上】的东西放进去,把噪音清出去。
# 下一节的对话历史管理,做的就是这件事。
# === 小结 ===
# ★ "越聊越笨"的真相不只是没塞够更是塞太多。★ 一个反
# 直觉现象 lost in the middle:直觉以为只要没超窗口塞
# 进去的每个字模型都同等认真读 —— 错;研究和实践反复
# 证实,上下文很长时模型对开头和结尾注意力最强记得最牢、
# 对正中间那一大段注意力明显衰减经常像没看见,这解释了
# 开头的 bug —— "我是做财务的"在长对话里被挤到历史正
# 中间,模型不是忘了是根本没认真看那句。★ 推论一上下文
# 不是装得下就行:128K 窗口不代表塞到 127K 还一样好用,
# 塞越满中间被迷失的越多有效信息浓度越低,窗口大小是
# 物理上限不是推荐用量。★★ 推论二位置是一种你能控制的
# 权重:模型偏爱开头结尾,最重要最不能忽略的指令(人设
# 铁律)放最开头 system、跟本轮问题最相关的关键信息
# (检索资料、用户刚强调的约束)尽量靠近结尾靠近用户
# 当前问题、不关键的早期闲聊才放中间甚至该被裁掉。
# ★ 推论三别什么都往上下文里堆:看到模型答不好第一反应
# 往往是再多给点信息,但 lost in the middle 说明无脑
# 加料会让关键信息浓度更低反而更差,正确方向不是塞更多
# 是塞得更准 —— 只把这轮真正用得上的放进去把噪音清出去。
# ★ 利用"位置即权重":重要信息放开头和结尾,别埋在中间
def build_context_aware_messages(
system_rule: str, # 最重要:人设/铁律
chat_history: list, # 早期对话(不那么关键)
retrieved_docs: str, # 跟本轮强相关的检索资料
user_question: str, # 用户当前问题
) -> list:
msgs = []
# === ① 开头:模型注意力最强处 -> 放最不能被忽略的指令 ===
msgs.append({"role": "system", "content": system_rule})
# === ② 中间:注意力会衰减的区域 -> 放早期历史 ===
# ★★ 关键认知:放在这里的东西,模型"可能看不太清"。
# 所以只有"不那么关键"的早期闲聊,才适合待在中间
for turn in chat_history:
msgs.append({"role": "user", "content": turn["q"]})
msgs.append({"role": "assistant", "content": turn["a"]})
# === ③ 结尾:模型注意力第二强处 -> 放本轮强相关信息 ===
# ★★ 把检索到的资料、用户刚强调的关键约束,紧贴着
# 当前问题放 —— 这是模型最"看得见"的位置
msgs.append({
"role": "user",
"content": (
f"【参考资料】\n{retrieved_docs}\n\n"
f"【当前问题】\n{user_question}" # ★ 问题放最后一行
),
})
return msgs
# ★ 同样的信息,放对位置 = 模型"看得见";放错位置 = 被 lost
对话历史管理:截断、滑动窗口、摘要压缩
# === ★ 核心矛盾:历史要留(为连贯),又不能全留(为成本) ===
# === ★ 策略一:滑动窗口 —— 只保留最近 N 轮 ===
# ★ ★ 最简单粗暴:永远只把【最近的 N 轮】对话带上,
# 更早的直接丢掉。比如只留最近 10 轮。
# ★ ★ 优点:实现简单,token 量有了硬上限,成本可控。
# ★ ★★ 缺点很明显:被丢掉的早期信息就【真的没了】。
# "我是做财务的"如果是第 1 轮说的,聊到第 15 轮,
# 它早被滑出窗口 —— 模型当然又不认识你了。
# 适合:不太依赖久远上下文的闲聊式场景。
# === ★★ 策略二:摘要压缩 —— 把旧历史"浓缩"成一段话 ===
# ★ ★ 更聪明的做法:当历史长到一定程度,不直接丢,
# 而是【调用模型,把这一大段旧对话,总结成一小段
# 摘要】。然后用这段几百字的摘要,去【替换】掉那
# 几千字的原始历史。
# ★ ★★ 这样,早期的【关键事实】("用户是财务""在讨论
# 报表口径")被摘要保留了下来,而冗长的原始措辞被
# 压缩掉了。连贯性和成本,取得了平衡。
# ★ ★ 代价:摘要本身要花一次模型调用;且摘要必然有
# 损 —— 一些细节会在压缩中丢失。
# === ★ 策略三:混合 —— 摘要 + 最近 N 轮原文 ===
# ★ ★ 生产里最常用的,是把前两者【合起来】:
# - 很久以前的对话 -> 压缩成【一段摘要】,放在前面;
# - 最近的 N 轮对话 -> 保留【完整原文】,放在后面。
# ★ ★ 这样既有"远期记忆的要点"(摘要),又有"近期
# 对话的全部细节"(原文),是连贯性、成本、实现
# 复杂度之间一个相当不错的平衡点。
# === ★ 一个常被忘记的点:摘要也要管 system 里的关键事实 ===
# ★ ★ 像"用户是财务""用户叫张三"这种【贯穿全程的
# 关键身份信息】,最稳妥的不是指望它留在某轮对话里,
# 而是单独抽出来,塞进 system prompt 或一个固定的
# "用户画像"区块 —— 让它永远不会被任何裁剪、压缩
# 策略波及。
# === 小结 ===
# ★ 核心矛盾:历史要留(为连贯)又不能全留(为成本)。
# ★ 策略一滑动窗口只保留最近 N 轮:永远只带最近 N 轮、
# 更早的直接丢(如只留最近 10 轮),优点是实现简单
# token 有硬上限成本可控,缺点是被丢的早期信息真的没了
# ("我是做财务的"若是第 1 轮说的聊到第 15 轮早被滑出
# 窗口),适合不太依赖久远上下文的闲聊场景。★★ 策略二
# 摘要压缩把旧历史浓缩成一段话:历史长到一定程度不直接
# 丢而是调用模型把一大段旧对话总结成一小段摘要、用几百
# 字摘要替换几千字原始历史,早期关键事实被保留冗长措辞
# 被压缩,代价是摘要本身要花一次模型调用且必然有损。
# ★ 策略三混合摘要+最近 N 轮原文:生产最常用是合起来 ——
# 很久以前的对话压缩成一段摘要放前面、最近 N 轮保留完整
# 原文放后面,既有远期记忆要点又有近期对话全部细节,是
# 连贯性成本复杂度之间不错的平衡点。★ 一个常被忘记的点:
# "用户是财务""用户叫张三"这种贯穿全程的关键身份信息,
# 最稳妥的不是指望它留在某轮对话里而是单独抽出塞进
# system prompt 或固定的"用户画像"区块,让它永远不被
# 任何裁剪压缩策略波及。
# ★ 对话历史管理:混合策略 —— 旧历史摘要 + 最近 N 轮原文
import tiktoken
from openai import OpenAI
client = OpenAI()
enc = tiktoken.encoding_for_model("gpt-4o")
KEEP_RECENT_TURNS = 6 # ★ 最近 6 轮:保留完整原文
TOKEN_BUDGET = 3000 # ★ 给"历史"部分定一个 token 预算
def manage_history(history: list, summary: str) -> tuple:
"""返回 (新摘要, 要保留原文的最近若干轮)。"""
# ★ 最近 N 轮永远保留原文 —— 近期细节最关键
recent = history[-KEEP_RECENT_TURNS:]
older = history[:-KEEP_RECENT_TURNS] # ★ 更早的,准备压缩
if not older:
return summary, recent
# === ★★ 把"旧摘要 + 这批更早的对话"重新压成一段新摘要 ===
old_text = "\n".join(f"用户:{t['q']}\n助手:{t['a']}" for t in older)
prompt = (
f"已有对话摘要:\n{summary or '(无)'}\n\n"
f"新增对话:\n{old_text}\n\n"
"请把以上内容压缩成一段简洁摘要,"
"★ 务必保留:用户身份、明确偏好、未决问题、关键结论。"
)
resp = client.chat.completions.create(
model="gpt-4o-mini", # ★ 摘要用便宜的小模型即可
messages=[{"role": "user", "content": prompt}],
)
new_summary = resp.choices[0].message.content
return new_summary, recent
def build_messages(system_rule: str, user_profile: str,
summary: str, recent: list, new_q: str) -> list:
msgs = [
# ★★ 关键身份单独固定在 system,永不被裁剪/压缩波及
{"role": "system", "content": f"{system_rule}\n\n【用户画像】{user_profile}"},
]
if summary:
# ★ 远期记忆:用一段摘要代替几千字原文
msgs.append({"role": "system", "content": f"【早期对话摘要】{summary}"})
for t in recent: # ★ 近期记忆:完整原文
msgs.append({"role": "user", "content": t["q"]})
msgs.append({"role": "assistant", "content": t["a"]})
msgs.append({"role": "user", "content": new_q})
return msgs
系统提示词与上下文摆放:把"地基"打稳
# === ★ 同样的内容,摆放方式不同,效果天差地别 ===
# === ★ system prompt 是"地基",不是"第一句话" ===
# ★ ★ 很多人把 system prompt 当成"对话的第一句"。不对。
# 它是整场对话的【地基和宪法】:它定义助手是谁、必须
# 遵守什么、绝不能做什么。它的权重,天然高于后面任何
# 一句 user 消息。
# ★ ★ 所以"用户是谁""有什么硬规则""输出什么格式"这类
# 【全程不变的事实和铁律】,都应该进 system,而且要
# 写得明确、结构化(分点、用标题),别写成一团口语。
# === ★★ 摆放的总原则:重要的靠两端,易变的靠后 ===
# ★ ★ 把前面两节的结论合起来,一个长上下文请求,理想的
# 结构是这样【从前到后】排:
# - ① system:人设 + 铁律 + 用户画像(最重要,且不变);
# - ② 早期历史的【摘要】(次要,且不变);
# - ③ 最近 N 轮对话【原文】(较重要,每轮变);
# - ④ 本轮强相关的【检索资料】(很重要,每轮变);
# - ⑤ 用户【当前问题】(最重要,放在最末尾)。
# ★ ★ 这个顺序同时满足三件事:重要信息落在模型注意力强
# 的两端、不变的内容集中在前面利于缓存命中、当前问题
# 紧贴结尾最被"看见"。
# === ★ 一个细节:别让"工具/资料"淹没"指令" ===
# ★ ★ 当你往上下文里塞了一大段检索资料,有时模型会
# "看资料看入迷",反而忽略了 system 里的格式或纪律
# 要求。
# ★ ★ 一个有效的小技巧:在【最后】、紧挨着用户问题的
# 地方,用一句话【再强调一遍】最关键的那条指令 ——
# "请只依据上面的参考资料回答,并保持简洁。"
# 利用结尾的高注意力,给关键纪律【上一道双保险】。
# === 小结 ===
# ★ 同样的内容摆放方式不同效果天差地别。★ system prompt
# 是地基不是第一句话:很多人把它当对话第一句 —— 不对,
# 它是整场对话的地基和宪法,定义助手是谁、必须遵守什么、
# 绝不能做什么,权重天然高于后面任何 user 消息;所以
# "用户是谁""有什么硬规则""输出什么格式"这类全程不变
# 的事实和铁律都应进 system,且写得明确结构化(分点、
# 用标题)别写成一团口语。★★ 摆放总原则重要的靠两端
# 易变的靠后:一个长上下文请求理想结构从前到后是 ①
# system(人设+铁律+用户画像,最重要且不变)② 早期历史
# 的摘要(次要且不变)③ 最近 N 轮对话原文(较重要每轮
# 变)④ 本轮强相关检索资料(很重要每轮变)⑤ 用户当前
# 问题(最重要放最末尾);这顺序同时满足重要信息落在
# 注意力强的两端、不变内容集中前面利于缓存命中、当前
# 问题紧贴结尾最被看见。★ 一个细节别让工具/资料淹没
# 指令:塞一大段检索资料时模型有时会看资料看入迷反而
# 忽略 system 里的格式或纪律要求,有效技巧是在最后紧挨
# 用户问题处用一句话再强调一遍最关键的指令,利用结尾的
# 高注意力给关键纪律上一道双保险。
# ★ 一个结构稳定、注意力友好、利于缓存的 messages 组装函数
def assemble_request(
persona: str, # 人设 + 铁律(不变)
user_profile: str, # 用户画像(整场不变)
history_summary: str, # 早期历史摘要(基本不变)
recent_turns: list, # 最近 N 轮原文(每轮变)
retrieved: str, # 本轮检索资料(每轮变)
question: str, # 当前问题(每轮变)
) -> list:
msgs = []
# ===== 区段 A:不变内容,集中放最前 —— 利于 prompt 缓存命中 =====
# ★★ persona 和 user_profile 整场对话稳定,放一起、放最前
msgs.append({"role": "system", "content":
f"{persona}\n\n【用户画像】\n{user_profile}"})
if history_summary:
msgs.append({"role": "system", "content":
f"【早期对话摘要】\n{history_summary}"})
# ===== 区段 B:近期原文,放中间 =====
for t in recent_turns:
msgs.append({"role": "user", "content": t["q"]})
msgs.append({"role": "assistant", "content": t["a"]})
# ===== 区段 C:本轮强相关内容 + 问题,放最后 —— 注意力最强 =====
msgs.append({"role": "user", "content": (
f"【参考资料】\n{retrieved}\n\n"
f"【当前问题】\n{question}\n\n"
# ★★ 双保险:在结尾高注意力处,再强调一遍最关键的纪律
"请仅依据上述参考资料作答;资料中没有的,明确说明无法回答。"
)})
return msgs
工程坑:token 超限、流式、缓存命中、会话存储
# === ★ 把对话助手做稳,还有四个坑 ===
# === ★ 坑一:发请求前,必须先估 token、做超限保护 ===
# ★ ★ 别等模型返回"上下文超长"的报错才处理。每次发请求
# 【之前】,用 tiktoken 把这一包的 token 数算出来,
# 和模型窗口上限比一比。
# ★ ★ 如果逼近上限,要【主动】触发裁剪/摘要,把它压回
# 安全线以内,再发。还要给【输出】预留足够空间 ——
# 别把窗口占到 99%,留给模型生成回答的余地就没了。
# === ★★ 坑二:流式输出,不改变 token 成本 ===
# ★ ★ 很多人以为开了流式(stream),逐字蹦出来,会更
# 省钱。【不会。】流式只改变"回答怎么呈现给用户"
# (体验上更快),它【不改变】这次请求消耗的输入和
# 输出 token 总量。
# ★ ★ 流式真正解决的是【体感延迟】:用户不用对着空白
# 等几秒,看到字在动,焦虑就小。成本账,该多少还是
# 多少。
# === ★ 坑三:缓存命中,败给一个动态前缀 ===
# ★ ★ 上面说 prompt 缓存能省钱,但它有个脆弱点:它命中
# 的是【从头开始完全一致】的前缀。
# ★ ★★ 一个极常见的失误:在 system prompt 里写了
# "当前时间是 2024-06-01 15:30:21"这类【每次都变】
# 的东西。就这一个动态值,会让它【后面所有内容】的
# 缓存【全部失效】 —— 因为前缀不再一致了。
# ★ ★ 所以:动态内容(时间戳、随机 ID)绝不能混进那段
# 本应稳定的前缀里,要把它们挪到请求的【靠后位置】。
# === ★ 坑四:会话状态,得有个地方存 ===
# ★ ★ "模型无状态、记忆在你这边"——那"你这边"具体是
# 哪儿?如果只存在某个服务实例的内存里,这个实例一
# 重启、或者请求被负载均衡打到另一个实例,用户的整段
# 对话历史就【凭空消失】了。
# ★ ★ 多轮对话的会话历史(messages、摘要、用户画像),
# 要存进一个【外部共享存储】—— 比如 Redis(配上合理
# 的过期时间)或数据库。会话 ID 跟着请求走,任何实例
# 都能凭它捞回完整历史。
# === 认知 ===
# ★ 把对话助手做稳还有四个坑。★ 坑一发请求前必须先估
# token 做超限保护:别等模型返回"上下文超长"报错才
# 处理,每次发请求前用 tiktoken 算出这一包 token 数和
# 模型窗口上限比一比,逼近上限就主动触发裁剪/摘要压回
# 安全线再发,还要给输出预留足够空间别把窗口占到 99%。
# ★★ 坑二流式输出不改变 token 成本:很多人以为开流式
# 逐字蹦出来更省钱 —— 不会,流式只改变"回答怎么呈现给
# 用户"(体验更快)不改变这次请求消耗的输入输出 token
# 总量,它真正解决的是体感延迟、成本账该多少还多少。
# ★ 坑三缓存命中败给一个动态前缀:prompt 缓存命中的是
# 从头开始完全一致的前缀,极常见失误是在 system prompt
# 里写"当前时间是 2024-06-01 15:30:21"这类每次都变的
# 东西,就这一个动态值会让后面所有内容的缓存全部失效,
# 动态内容(时间戳、随机 ID)绝不能混进本应稳定的前缀、
# 要挪到请求靠后位置。★ 坑四会话状态得有个地方存:模型
# 无状态记忆在你这边,但若只存某个服务实例内存里,实例
# 一重启或请求被负载均衡打到另一实例用户整段历史就凭空
# 消失,会话历史(messages、摘要、用户画像)要存进外部
# 共享存储(Redis 配合理过期时间或数据库)、会话 ID
# 跟着请求走任何实例都能凭它捞回完整历史。
# ★ 工程加固:发送前 token 守卫 + 会话存进 Redis(跨实例)
import json, tiktoken, redis
from openai import OpenAI
client = OpenAI()
enc = tiktoken.encoding_for_model("gpt-4o")
rds = redis.Redis(host="127.0.0.1", port=6379, decode_responses=True)
MODEL_WINDOW = 128_000 # ★ 模型上下文窗口上限
RESERVE_FOR_OUTPUT = 4_000 # ★★ 必须给"生成回答"预留空间
def count_messages_tokens(messages: list) -> int:
return sum(len(enc.encode(m["content"])) for m in messages
if isinstance(m.get("content"), str))
# === ★ 坑一:发送前的 token 守卫 ===
def guard_and_send(messages: list, summarize_fn) -> str:
used = count_messages_tokens(messages)
# ★★ 输入不能逼近窗口 —— 要给输出留够 RESERVE_FOR_OUTPUT
while used > MODEL_WINDOW - RESERVE_FOR_OUTPUT:
# ★ 主动裁剪/摘要,把这一包压回安全线,而不是等报错
messages = summarize_fn(messages)
used = count_messages_tokens(messages)
resp = client.chat.completions.create(
model="gpt-4o", messages=messages,
max_tokens=RESERVE_FOR_OUTPUT, # ★ 显式给输出上限
)
return resp.choices[0].message.content
# === ★ 坑四:会话历史存 Redis,跨实例可恢复 ===
def load_session(session_id: str) -> list:
raw = rds.get(f"chat:{session_id}")
return json.loads(raw) if raw else [] # ★ 任何实例都能捞回
def save_session(session_id: str, messages: list):
# ★★ 存外部共享存储 + 设过期时间,实例重启/换实例都不丢
rds.set(f"chat:{session_id}", json.dumps(messages, ensure_ascii=False),
ex=7 * 24 * 3600) # ★ 7 天过期
一张图:一次多轮对话请求的组装与治理流程
关键概念与配置速查
┌──────────────────────────┬──────────────────────────────────┐
│ 概念 / 做法 │ 说明 │
├──────────────────────────┼──────────────────────────────────┤
│ token │ 模型计费与计容量的单位,非字符数 │
│ tiktoken │ 发请求前用它精确数 token │
│ 上下文窗口 │ 单次请求 输入+输出 的 token 上限 │
│ 模型无状态 │ 记忆全靠你每轮重发 messages │
│ 成本随轮数 │ 平方级增长(每轮重发全部历史) │
│ system 角色 │ 不变的人设/铁律只放这一条 │
│ prompt caching │ 不变前缀放最前,命中后大幅打折 │
│ lost in the middle │ 长上下文中间段注意力衰减 │
│ 摆放原则 │ 重要靠两端,易变靠后,问题放最末 │
│ 滑动窗口 / 摘要 / 混合 │ 三种历史管理策略,生产常用混合 │
│ max_tokens │ 给输出留空间,别让输入占满窗口 │
│ 会话存储 │ messages 存 Redis/DB,跨实例可恢复 │
└──────────────────────────┴──────────────────────────────────┘
★ 排查"越聊越笨/越贵":第一步打印真正发出去的请求体,看它多大
★ 关键身份(用户是谁)抽进 system,别指望它留在某轮对话里
★ 动态值(时间戳)绝不混进可缓存前缀,否则缓存全失效
避坑清单:做多轮 AI 对话前过一遍这 10 条
- 记住模型是无状态的。它不会"自己记得"你们聊过什么。多轮对话的记忆,完全靠你每一轮把历史重新打包发回去。想不通问题时,回到这一条。
- 用 token 思考,不要用字符数。计费和容量都按 token 算。发请求前用 tiktoken 精确计数,中文的 token 数尤其要实测,别拍脑袋估。
- 上下文窗口是输入+输出共用的。别把输入塞到接近窗口上限,必须用 max_tokens 给模型生成回答预留出足够空间,否则答案会被截断。
- 对话成本是平方级增长的。每轮都重发全部历史,聊得越久单次请求越大。账单翻十倍不是模型问题,是你没管理历史。
- 不变的指令只放一条 system 消息。别在每轮 user 消息里重复抄人设和规则——抄一遍就多付一遍钱,还挤占了上下文。
- 警惕 lost in the middle。长上下文里,模型对开头结尾注意力强、对中间弱。把关键信息放两端,别埋在历史正中间。
- 历史要管理:滑动窗口、摘要、或混合。生产环境常用"早期摘要+最近 N 轮原文"的混合策略,在连贯性和成本之间取得平衡。
- 关键身份信息单独固定在 system。"用户是财务""用户叫张三"这类贯穿全程的事实,抽进 system 或用户画像区块,别让它被裁剪、压缩策略波及。
- 别让动态值毁掉 prompt 缓存。缓存命中的是完全一致的前缀。时间戳、随机 ID 混进 system 前缀,会让后面所有内容的缓存全部失效。
- 会话状态存外部共享存储。别把对话历史只放在服务实例内存里。存 Redis 或数据库,配会话 ID,实例重启或被负载均衡换实例时才不丢。
总结:你不是在"和模型聊天",你是在"反复递交一份卷宗"
那个 AI 助手的问题排查清楚之后,我对着请求日志坐了很久。最初让我困惑的三个投诉——越聊越笨、越聊越贵、越聊越慢——我一度以为是三个独立的毛病,甚至以为是模型本身的退化。可当我真正看懂了那个塞满几万字历史的请求体,才发现它们根本是【同一个】病因的三种表现:我把对话历史,原封不动、毫无节制地,一轮一轮往上堆。堆出来的体积,直接变成了账单(贵)、变成了延迟(慢);而堆出来的、又长又稀的上下文,让关键信息在中间迷失,于是模型(笨)。一个根,三个症状。
这次复盘最颠覆我认知的,是那句「模型是无状态的」。在用它之前,我脑子里的画面是「我在和一个有记忆的智能体对话,它记得我们聊过什么」。但真相要朴素得多,也清醒得多:模型没有记忆,每一次请求,它都是第一次见你。你以为的「连续对话」,实际上是你这边,把一份越来越厚的卷宗,一次又一次地、完整地递到它面前,让它当场从头读一遍,然后回答你最后一页的那个问题。它读得快,所以你感觉它「记得」。可一旦那份卷宗厚到几万字,问题就全来了——递交它很贵,它读完很慢,而且厚卷宗的正中间,它会看得很潦草。
想通了这个,工程上的全部工作就有了一个清晰的方向:既然我是在反复递交一份卷宗,那我的核心任务,就是把这份卷宗,始终维持在一个又薄、又准、又重点突出的状态。该归档的旧内容,压缩成摘要;最相关的近期内容,保留原文;最重要的身份和铁律,钉死在第一页;每一次递交前,都掂量一下它的厚度,超了就先精简。token、上下文窗口、滑动窗口、摘要压缩、缓存——这些听起来很「技术」的词,背后都是同一件朴素的事:管好你递交给模型的那份卷宗。
那个 AI 助手重做之后,稳定了下来。聊几十轮,它依然记得你是做财务的,响应依然很快,账单也回落到了合理的水平——因为现在,无论用户聊多久,真正发到模型那里的卷宗,厚度始终是可控的。我后来跟团队总结这件事时说了一句话:做大模型应用,你写的代码里,真正「调用模型」的可能只有一行;而剩下的功夫,几乎全花在「在调用它之前,精心准备好那份要递给它的上下文」上。模型的智能是厂商给的,但喂给它什么、怎么喂——那份卷宗的厚薄与编排,从头到尾,都是你的工程。
—— 别看了 · 2026