大模型上下文窗口完全指南:为什么 AI 对话越聊越贵、越聊越笨

2024 年我做一个内部 AI 对话助手,刚上线时又快又准,用着用着用户开始抱怨:同一个会话越聊越慢、回答越来越笨、账单还越滚越高。我一度以为是模型质量不稳定想换模型,直到把每次请求真正发出去的 messages 数组打印出来才看明白——问题根本不在模型,而在我每次请求都把整段对话历史原封不动塞回去。模型是无状态的,它不记得上一句话,所谓"多轮对话"全靠你每次把历史重新递交一遍;历史越长,单次请求的 token 越多,成本随轮数近似平方级增长,而且长上下文里中间部分会被模型"看漏"(lost in the middle)。梳理:先认清 token 和上下文窗口是什么、为什么对话会平方级变贵;再用滑动窗口、摘要压缩、混合策略管理对话历史,把该留的留下、该压的压掉;系统提示词和关键信息要放在上下文的头尾而不是中间;最后还有 token 超限、流式输出、prompt 缓存命中、会话存储四个工程坑要逐个设防。核心一句:你不是在"和模型聊天",你是在每一次请求里"反复递交一份卷宗"——卷宗多厚、怎么编排,决定了它多贵、多聪明。

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 条

  1. 记住模型是无状态的。它不会"自己记得"你们聊过什么。多轮对话的记忆,完全靠你每一轮把历史重新打包发回去。想不通问题时,回到这一条。
  2. 用 token 思考,不要用字符数。计费和容量都按 token 算。发请求前用 tiktoken 精确计数,中文的 token 数尤其要实测,别拍脑袋估。
  3. 上下文窗口是输入+输出共用的。别把输入塞到接近窗口上限,必须用 max_tokens 给模型生成回答预留出足够空间,否则答案会被截断。
  4. 对话成本是平方级增长的。每轮都重发全部历史,聊得越久单次请求越大。账单翻十倍不是模型问题,是你没管理历史。
  5. 不变的指令只放一条 system 消息。别在每轮 user 消息里重复抄人设和规则——抄一遍就多付一遍钱,还挤占了上下文。
  6. 警惕 lost in the middle。长上下文里,模型对开头结尾注意力强、对中间弱。把关键信息放两端,别埋在历史正中间。
  7. 历史要管理:滑动窗口、摘要、或混合。生产环境常用"早期摘要+最近 N 轮原文"的混合策略,在连贯性和成本之间取得平衡。
  8. 关键身份信息单独固定在 system。"用户是财务""用户叫张三"这类贯穿全程的事实,抽进 system 或用户画像区块,别让它被裁剪、压缩策略波及。
  9. 别让动态值毁掉 prompt 缓存。缓存命中的是完全一致的前缀。时间戳、随机 ID 混进 system 前缀,会让后面所有内容的缓存全部失效。
  10. 会话状态存外部共享存储。别把对话历史只放在服务实例内存里。存 Redis 或数据库,配会话 ID,实例重启或被负载均衡换实例时才不丢。

总结:你不是在"和模型聊天",你是在"反复递交一份卷宗"

那个 AI 助手的问题排查清楚之后,我对着请求日志坐了很久。最初让我困惑的三个投诉——越聊越笨、越聊越贵、越聊越慢——我一度以为是三个独立的毛病,甚至以为是模型本身的退化。可当我真正看懂了那个塞满几万字历史的请求体,才发现它们根本是【同一个】病因的三种表现:我把对话历史,原封不动、毫无节制地,一轮一轮往上堆。堆出来的体积,直接变成了账单(贵)、变成了延迟(慢);而堆出来的、又长又稀的上下文,让关键信息在中间迷失,于是模型(笨)。一个根,三个症状。

这次复盘最颠覆我认知的,是那句「模型是无状态的」。在用它之前,我脑子里的画面是「我在和一个有记忆的智能体对话,它记得我们聊过什么」。但真相要朴素得多,也清醒得多:模型没有记忆,每一次请求,它都是第一次见你。你以为的「连续对话」,实际上是你这边,把一份越来越厚的卷宗,一次又一次地、完整地递到它面前,让它当场从头读一遍,然后回答你最后一页的那个问题。它读得快,所以你感觉它「记得」。可一旦那份卷宗厚到几万字,问题就全来了——递交它很贵,它读完很慢,而且厚卷宗的正中间,它会看得很潦草。

想通了这个,工程上的全部工作就有了一个清晰的方向:既然我是在反复递交一份卷宗,那我的核心任务,就是把这份卷宗,始终维持在一个又薄、又准、又重点突出的状态。该归档的旧内容,压缩成摘要;最相关的近期内容,保留原文;最重要的身份和铁律,钉死在第一页;每一次递交前,都掂量一下它的厚度,超了就先精简。token、上下文窗口、滑动窗口、摘要压缩、缓存——这些听起来很「技术」的词,背后都是同一件朴素的事:管好你递交给模型的那份卷宗。

那个 AI 助手重做之后,稳定了下来。聊几十轮,它依然记得你是做财务的,响应依然很快,账单也回落到了合理的水平——因为现在,无论用户聊多久,真正发到模型那里的卷宗,厚度始终是可控的。我后来跟团队总结这件事时说了一句话:做大模型应用,你写的代码里,真正「调用模型」的可能只有一行;而剩下的功夫,几乎全花在「在调用它之前,精心准备好那份要递给它的上下文」上。模型的智能是厂商给的,但喂给它什么、怎么喂——那份卷宗的厚薄与编排,从头到尾,都是你的工程。

—— 别看了 · 2026
声明:本站所有文章,如无特殊说明或标注,均为本站原创发布。任何个人或组织,在未征得本站同意时,禁止复制、盗用、采集、发布本站内容到任何网站、书籍等各类媒体平台。如若本站内容侵犯了原著者的合法权益,可联系我们进行处理 邮箱1846861578@qq.com。
技术教程

消息队列完全指南:从一次库存扣三次的事故看懂丢失、重复、顺序怎么治

2026-5-21 16:31:19

技术教程

MySQL 索引失效完全指南:从一次 8 秒慢查询看懂索引为什么没走

2026-5-21 16:46:11

0 条回复 A文章作者 M管理员
    暂无讨论,说说你的看法吧
个人中心
购物车
优惠劵
今日签到
有新私信 私信列表
搜索