1. 多轮对话为什么会越长越崩先算清token账多轮对话做到一定深度你迟早会遇到一个很闹心的问题用户和机器人聊了几十轮之后突然某条消息开始报错模型接口返回上下文超长的异常。第一次碰见时我以为是自己代码里的参数写错了查了半天才发现问题出在消息历史上——每一次请求模型看到的都不是最后一条消息而是整个对话从头到尾的全部内容。这个机制的代价用一个简单的类比就能讲清楚每次开会你都要把会议室里从第一天起的每一句话都复述一遍然后才说今天要讨论的新内容。会话越长复述成本越高最终高到会议室装不下。装不下的时候就得截断而截断多轮对话的上下文这件事就是让模型在有限的窗口里尽可能保留有用的信息。1.1 每次请求模型都在阅读全部历史主流对话模型的标准输入格式是一条消息数组从最早的system到最近一次的user层层叠叠排在一起。用户每发一句话你都要把这个数组整个发给模型让模型重新读一遍再作答。这意味着两件事第一token消耗是线性增长的。假设每轮对话平均300个token20轮就是6000个token。如果系统提示词还带着长长的角色设定和工具说明比如500个token那一次请求累加下来很快就突破7000。第二输出还要占地方。模型生成回复时输入上下文加上输出内容不能超过窗口限制。我用一个8k窗口来举例系统的角色说明占500最近20轮历史占6000用户这条新消息占200那剩下用来生成回复的位置最多只有1300个token左右。一旦生成的回复稍长一点API就会报错或者直接把回复切掉。1.2 一张8k窗口的账单演示为了把账算明白假设窗口是8000 token结构大致如下组成部分典型token数系统提示词人设工具说明500每轮对话一轮userassistant300最新用户输入200模型输出预留1000简单算一下500 200 1000 1700已经固定支出剩下6300给历史。按每轮300token算大约能容纳21轮。也就是说用户只要聊到22轮任何一轮的历史都可能超出预算。而现实中的对话往往带代码片段、长截图描述、工具返回的JSON一轮冲到500到800token非常正常那么撑过10轮就已经危险了。这个账还只是保守口径。真正上线之后问题还来自另一面上下文越长模型处理起来越慢显存占用越高。即使模型窗口理论上能撑到几十万token接口时延也会拉到不可接受的程度。所以截断不是上线之后的紧急补救而应该是对话链路里默认就存在的组件。1.3 为什么超了就丢没那么简单很多同学第一反应是超了就把最早的消息删掉呗。这个思路方向没错但直接删会带来三个一眼就能发现的副作用。第一个副作用是丢指令。初始阶段的对话往往包含着用户的明确偏好、产品必须遵守的规则、某个任务的背景信息。比如用户在一开始说过请用口语风格回答或者把结果都翻译成中文这些内容一旦被挤出窗口后半段对话的输出风格会立刻漂移。第二个副作用是丢状态。多轮对话里经常有刚才说的那个订单上一轮提到的文件这类指代关系依赖前文存在。你只保留最近五六轮模型根本不知道那个订单是哪个订单只能开始瞎编。第三个副作用是破坏消息结构。对话数组不是一串普通字符串它有严格的角色次序和成对关系。如果删得不讲究可能删成以一个assistant消息开头或者删掉半个工具调用轮回接口直接校验失败。所以说截断的核心不是删掉多余的字而是在有限的窗口里用尽可能低的成本保留最多的有效信息。为了讲清楚怎么做到这一点下面先看看业界普遍在用的三种截断思路以及它们各自的适用边界。2. 主流的三种截断策略各有各的取舍把截断这件事拆开来看它其实回答两个问题该留哪些消息该丢哪些消息不同方案对这两个问题的答案不同代价也不同。我按复杂程度从低到高把常见的做法分类说明。2.1 方案A按消息条数硬截断这是最直觉的做法维护一个队列只保留最近N条消息。比如N20队列满的时候就弹出最老的一条。优点很突出实现简单、逻辑透明、几乎没有额外计算。队列可以用现成的数据结构调试也容易。对于demo、原型、内部工具这类对上下文要求不高的场景这是性价比最高的方案。缺点也很明显消息长短不一按条数截断完全没考虑token预算。一个极端情况是最近的两条消息都是巨长的代码块或者工具返回总共已经占了5000token再安排10条历史消息窗口马上超了。另一个问题是如果N太小早期关键指令很容易被冲掉。所以在用这个方案的时候我会额外加一道保险数一下当前消息数组的总token数超过某个阈值比如窗口的70%才触发截断而不是每次有新消息都无脑弹掉一条。这样短对话不会被误伤长对话也能稳定工作在安全水位。2.2 方案B按token预算滑动窗口既然问题本质是token超限那最直接的解法就是按token来算账。典型流程是这样的设定一个历史预算H从最新的消息开始往前遍历一条一条累加token数直到累加结果达到H再往前的消息全部丢弃。这种做法的好处是精确窗口利用率高。无论消息长短都能保证历史部分的token不会超过预算。而且实现也不算复杂关键是要有两个基础能力能精确统计每条消息的token数能把消息按完整回合分组避免拦腰截断。方案B真正要小心的坑有两个。第一system消息必须无条件保留。有些框架会把角色说明、工具定义全塞进system里这部分内容对对话质量影响极大不能因为超预算就被滑出窗口。第二输出token也要留够。历史预算H不能等于整个窗口大小必须把最新用户输入和模型输出预留出去否则就会出现塞满历史但没法回答的尴尬局面。2.3 方案C摘要降维用一次小开销换长程记忆方案A和B都是在丢而方案C是换不用等到窗口塞满就主动调用模型把一段较长的历史对话压缩成一段几十个token的摘要然后让摘要占据最早的位置后面继续保留最近几轮完整对话。这样做的效果非常像人类记笔记——古老的细节不需要逐字保留但关键结论需要记住。这个方案的优点是压缩率高长对话可以被压缩到原来的十分之一甚至更低而且能够跨会话保持一致性。缺点也很实在每做一次摘要就要多消耗一次模型调用增加几十毫秒到几百毫秒的延迟压缩的过程本身也可能丢细节如果摘要做得不好模型会假装记得但实际上记错了。从实践来看我的建议是把它作为方案B的补充而不是替代。具体做法是历史长度超过预算一半时先把最早的若干轮做一次摘要替换成一条user占位的摘要消息再继续用token预算窗口保留最新的完整对话。这样既拿到了摘要的长程记忆又保留了最近对话的细节延迟也只增加在历史变长之后。把三种方案放在一起比较会更直观策略核心原理成本长程记忆推荐场景条数截断保留最近N条消息极低弱原型、demo、短对话token预算窗口按token从近到远装配低中生产环境默认方案摘要降维压缩早期对话为摘要中强长会话、跨轮任务3. 手写一个保system、保最近、按token预算的截断器理论聊完该动手了。下面这套代码是我目前在对话服务里常用的截断逻辑思路就是方案B加了方案C的接口预留位。你可以直接拿来改关键点我都写了注释。3.1 token计数别用strlen用编码器截断器能不能精准工作第一步取决于token统计的准确度。千万不要用Python的len(text)去数中英文混合场景下误差能到50%以上这个我在后面踩坑部分会展开说。正确的做法是用模型对应的tokenizer编码器。很多大模型厂商都提供了对应的Python包比如tiktoken是不少模型的默认统计工具安装之后拿编码器对文本做encode返回数组的长度就是token数。如果用的是其他模型通常也有对应的tokenizer逻辑一致。需要说明的是每条消息在API里除了content正文还有role字段和消息结构产生的额外token开销。我在实际实现里会给每条消息固定加一个常数用来覆盖这部分开销避免算出来的总token比API实际统计少。这个常数不同的接口有差异一般取4到6是安全的。3.2 核心实现下面是一个精简但完整的截断器。它接收原始消息数组、系统提示词、窗口总大小和输出预留值返回截断后的消息数组import tiktoken def build_token_counter(model_name: str): enc tiktoken.encoding_for_model(model_name) return lambda text: len(enc.encode(text)) def count_message_tokens(msg: dict, count_fn) - int: 统计单条消息的token开销含角色和结构开销 content msg.get(content) or # 4是消息结构开销的经验值按你用的接口可以调整 return count_fn(content) 4 def group_into_turns(messages): 把消息按回合分组每组从一条user消息开始 turns [] current [] for m in messages: current.append(m) if m[role] user: if current: turns.append(current) current [m] # assistant的tool_calls后续跟着的tool消息都会进同一组 if current: turns.append(current) return turns def truncate_messages(messages, system_prompt, max_tokens, output_reserve, count_fn): budget_per_req max_tokens - output_reserve # 可用的历史输入空间 fixed [{role: system, content: system_prompt}] used count_message_tokens(fixed[0], count_fn) 8 # system前通常还有格式开销 turns group_into_turns(messages) kept [] # 从最近的一组往回装直到预算耗尽 for turn in reversed(turns): turn_tokens sum(count_message_tokens(m, count_fn) for m in turn) if used turn_tokens budget_per_req: break kept.append(turn) used turn_tokens # 恢复顺序system永远在最前 result fixed [m for turn in reversed(kept) for m in turn] return result这个实现有几个值得注意的细节。第一个细节是output_reserve参数。它代表你打算给模型回复预留多少输出空间一般至少留1000到2000。如果你用接口的max_tokens字段同时控制输出长度那这里的output_reserve应该和max_tokens保持一致否则容易超窗口。第二个细节是group_into_turns。我没有简单地从最近一条消息往前删而是把消息按user开始、到下一个user前结束分组成回合。这样能保证不会把用户的提问留下、把对应的回答丢掉也不会把半个工具调用链截断。第三个细节是system消息的处理。我在函数里把system单独拆出来并固定放在最前面。如果原始messages里已经有system我会在调用前先过滤掉或者在分组时跳过避免出现重复system。3.3 单条超长消息的兜底还有一个边界要处理某一条消息本身就超过预算怎么办比如用户粘贴了一整篇文章或者工具返回了上万token的JSON。按上面的循环逻辑这条消息会被整个丢掉用户刚发的内容就没了这显然不行。现实中我的策略是分级别处理如果单条消息超预算但还能接受截断那就对content做尾部截断只保留开头一部分并追加一句话说明内容过长已省略中间部分如果连开头都放不下那说明这条消息本身就不适合当前模型窗口应该在业务层尽早提醒用户。对于工具返回的长JSON我更推荐让服务端先做字段裁剪只返回必要字段而不是指望截断器去兜底。3.4 接口验证与调试把截断器接到真实请求之前我建议先写一个小的断言用例构造30轮伪对话统计截断后的消息条数和token总数确认总token严格小于max_tokens - output_reserve。在调试时我会打印每次截断前后的token分布看看是不是总把同一类消息比如工具返回挤掉据此调整预算分配。我现在这个实现里历史预算和输出预留的比例大概在7比3。也就是说8k窗口历史加输入最多占5600token剩下2400留给输出。这个比例不是死的如果产品以短回复为主可以适当把历史预算调大如果经常需要模型生成大段内容就要反过来加大输出预留。4. 截断之后的连贯性修补从能用变成好用上面的截断器能保证不报错、不超限但离用户体验不塌还有距离。真正上线之后你会发现截断之后模型偶尔还是会犯迷糊问题往往出在信息丢失引发的指代混乱。这一章讲三个我常用的修补手段。4.1 用摘要替代残骸方案C的价值在这里体现。当对话很长时最先被截掉的通常是中段内容但这段内容里可能埋着关键约定。我的做法是每次触发截断前先看被丢弃的消息里有没有值得保留的结论如果有调用一次模型生成摘要把摘要作为一条user消息放在保留历史的开头。下面是我常用的摘要指令模板特意限定输出长度和内容范围SUMMARY_PROMPT 请把提供的对话压缩成一段不超过100字的要点摘要只保留 1. 用户明确表达的偏好或要求 2. 尚未完成的具体任务 3. 已经确认的结论或参数。 不要保留寒暄、客套和过程性描述。直接输出摘要内容。实际效果是被截断的历史变成了一条摘要最近对话模型既能知道前因又能看到最近的细节。代价就是多一次模型调用所以我只在历史token超过预算50%的时候才触发摘要否则直接交给截断器处理就够了。4.2 把关键信息硬化到system区摘要再精也是文本系统不会主动去用它。更稳妥的做法是硬化定期从对话中抽取必须长期记住的事实直接写进system提示词。比如用户说过我只需要价格不含税请用英文回复这类信息放进system后无论历史怎么截断模型都不会忘记它们。具体来说我通常在每完成一个业务轮次后运行一次轻量抽取把结果合并进system区EXTRACT_PROMPT 从对话中提取需要长期记住的事实输出JSON数组每个元素包含type和value字段。 type只能是preference偏好、task待办、conclusion结论之一。 只输出JSON数组本身不要解释。这个方案和摘要的区别在于摘要是给模型看的上下文而硬化信息是直接写进系统指令的永久记忆。两者可以同时存在而且我认为后者更值得投资——因为它不占历史预算总保持在系统提示词那一层。4.3 在完整的回合边界下刀最后是一个容易被忽略的细节截断点的选择。即使做了group_into_turns仍然要处理一种特殊情况——如果某回合被整体丢弃它的最后一条assistant消息恰好是好的我马上处理那么截断后第一条历史消息可能是一条孤零零的assistant失去上下文对应用户的提问。这时候模型很容易把这句话当成当前对话的起点产生幻觉。我的建议很简单截断后检查第一条非system消息的role如果不是user就继续往前丢直到出现user为止。这种做法能让模型看到一个相对完整的会话起点输出稳定性会好很多。对于少数模型第一条user之前允许有assistant的例外情况我建议也统一按user开头处理因为这是成本最低的规范。5. 我在生产环境踩过的四个截断坑代码写得再顺真正上线还是会遇到各种意外。下面四个坑都是我在实际项目中真实踩过的每一个都让对话当场失灵过写出来供你提前避雷。5.1 坑一字符串长度估算token误差大到离谱早期我图省事用len(text)估算消息长度结果生产环境频繁超窗。原因很简单不同内容的token密度差别太大。纯英文一个单词平均1到2个token中文一个汉字平均1到2个token但一个emoji表情可以占3到5个token一段带有大量符号的代码每个符号可能都要单独算。我做过一次抽样统计同样1000个字符的文本纯英文大约250token中英混合大约600token带大量代码和特殊符号的文本能到900token。如果你按len(text)预留预算实际超窗就只能靠API报错来发现这等于把截断器的精度寄托在运气上。结论就是预算计算必须走tokenizer绝不用字符数。5.2 坑二截掉半个工具调用轮回引入function calling之后多轮对话的消息结构变复杂了assistant消息可能带tool_calls字段后面跟着一条或多条tool消息回应这些调用。我最初按消息级别截断经常出现保留了assistant的tool_calls却丢掉了对应的tool结果的情况接口直接报校验错误大意是每条tool_calls都必须有匹配的tool响应。这个问题的解法就是前面代码里的group_into_turns把assistant发出的tool_calls到后续所有tool结果算成一个不可分割的单元要么整组保留要么整组丢弃。我还额外加了一条规则如果保留组内的assistant带tool_calls必须确保后续tool结果都在如果保不全就直接丢掉整个工具调用组改由业务层重新触发调用。5.3 坑三忘了给输出的token留位置某个版本的截断器把所有预算都给了历史输出预留设成了0。结果用户问复杂问题时接口频繁返回上下文长度已满即使返回成功回复也经常被拦腰截断出现半句话。因为模型在生成时会把输入历史和输出连续占住同一段空间。后来我把窗口预算拆成三份system固定占、历史加输入占、输出独立占。这个分配比例在上一章已经说过我用7比3输出至少保证1000个token。这个调整让超窗报错几乎消失了。如果你发现自己的截断器时灵时不灵先检查是不是输出预留没算进去。5.4 坑四截断后消息以assistant开头被接口拒绝最后一个坑很有意思某天截断器上线后线上偶发请求失败报错指向消息数组第一条消息不合法。排查半天发现是截断后第一条消息成了assistant。原因是用户发完消息后assistant回复还没进来截断逻辑基于最新消息最优先保留了这条assistant却把上一条user挤出去了。修复方法我在4.3里提过截断后强制第一条非system消息是user。这个检查虽然简单但值得放在截断器的收尾阶段每次返回前都断言一次。这个小小的断言后来帮我挡住了很多来自上游消息顺序的意外。到这里截断这件事就基本聊透了。如果你也在做多轮对话产品我的建议是别把截断当成最后一道保险而是当成消息链路的常规环节来设计。一个合理的截断器加上摘要与硬化信息的配合能让用户聊到五十轮的体验和聊到十几轮时保持一致这比任何花哨的重排策略都更实在。