在调用大语言模型接口时,输入Token往往比输出Token更容易失控。一次普通问答的Prompt可能只有几百Token,但接入真实业务后,System Prompt逐渐膨胀,历史消息不断累积,导致单次请求的输入Token成倍增长。账单翻倍通常不是模型单价变化,而是上下文管理出了问题。

消耗翻倍的直接推手:长System Prompt与历史上下文
System Prompt是每次请求都会完整携带的固定指令。很多应用为了稳定输出,会在System Prompt里写入角色设定、输出格式、约束条件、示例对话,甚至业务规则。这些内容一旦超过1500Token,再叠加多轮对话,输入Token很容易翻倍。以一个客服机器人为例:System Prompt包含产品知识、话术规范、禁用语和三个Few-shot示例,共约2200Token。用户平均对话10轮,每轮来回的消息被完整保留,最后一轮请求的输入Token可能达到8000Token以上,而其中真正对当前回答有效的上下文不足一半。
冗余上下文还包括重复出现的背景信息。比如每一轮用户消息前都拼接一段相同的业务说明,或者在对话历史中保留已经解决的历史问题。这些内容不会提升回答质量,却持续消耗Token。更隐蔽的是,如果使用一些Agent框架,工具调用结果会被原样回传,即使结果中包含大量无关字段,也会全部进入下一次请求。
要控制消耗,首先需要区分固定成本与可变成本。固定成本是每次请求都存在的System Prompt和公共背景;可变成本是历史对话、工具结果和检索到的参考内容。固定成本适合压缩与缓存,可变成本适合裁剪与摘要。两者分离后,优化方向会更加清晰。
第一步:量化Token并在日志中标记上下文占比
优化之前需要先看到数据。不同模型的Token切分方式略有差异,但OpenAI的tiktoken库可以近似统计。在请求前对System Prompt、历史消息、工具结果分别计数,并记录到日志中。这样就能发现哪一部分增长最快。
下面是一个简单的Python统计示例,用来计算消息列表的Token数量,并打印各部分占比:
import tiktoken
def count_tokens(text: str, model: str = "gpt-4") -> int:
enc = tiktoken.encoding_for_model(model)
return len(enc.encode(text))
def analyze_messages(system_prompt: str, history: list, current_query: str):
sys_tokens = count_tokens(system_prompt)
history_text = "\n".join([f"{m['role']}: {m['content']}" for m in history])
hist_tokens = count_tokens(history_text)
query_tokens = count_tokens(current_query)
total = sys_tokens + hist_tokens + query_tokens
print(f"System Prompt: {sys_tokens} tokens, {sys_tokens/total:.1%}")
print(f"History: {hist_tokens} tokens, {hist_tokens/total:.1%}")
print(f"Current Query: {query_tokens} tokens, {query_tokens/total:.1%}")
print(f"Total input: {total} tokens")
这段代码将输入拆分为三部分,分别统计Token并输出占比。在实际项目中,可以把这个统计放在请求前的预处理函数里,并将结果写入结构化日志。当发现System Prompt占比超过40%或历史消息占比超过60%时,就应该启动优化。
除了Token数量,还可以记录有效信息密度。有效信息密度可以用关键实体、时间范围、直接相关轮次来估算。例如,历史消息中最近三轮往往贡献了大部分回答所需信息,而更早的轮次可能只是寒暄或已解决事项。通过标记每条消息与当前问题的关联度,可以进一步指导裁剪策略。
第二步:压缩System Prompt,抽离公共上下文
System Prompt的优化目标不是越短越好,而是在保持指令清晰的前提下减少冗余表达。先检查是否存在以下问题:重复描述同一约束、示例过多、包含大量不随请求变化的静态知识、使用长句描述可以用列表表达的结构化规则。可以将规则整理为短句列表,去掉客套话和无关背景,把示例从三个减到一个或零个。
另一个有效手段是把公共上下文从System Prompt中抽离,改为按需注入。例如产品知识库不需要每次都完整放入System Prompt,而是先让模型判断是否需要查询,再通过工具调用或检索返回相关片段。这样System Prompt只保留角色、输出格式和基础行为约束,通常在300到800Token之间。对于必须携带的固定背景,可以在服务端缓存处理,避免重复序列化。
压缩时还要注意模型对指令位置的敏感度。一些模型对System Prompt开头的指令遵循更好,因此可以把最关键的三条约束放在最前面,次要内容后置或移到用户消息末尾。这样做即使删减后半部分,也不会明显影响输出质量。可以用小样本评测验证压缩前后的回答准确率与格式符合率,确保压缩没有引入副作用。
第三步:裁剪历史上下文,用滑动窗口和摘要替代全量回传
多轮对话场景下,全量回传历史消息是Token膨胀的主要原因。常见做法是设置滑动窗口,只保留最近N轮对话。例如只保留最近6轮或最近800Token的历史消息。这种方法实现简单,但直接丢弃早期信息可能导致上下文断裂,尤其是当用户提到前面说过的某个条件时,模型无法回忆。
更稳妥的策略是滑动窗口加摘要。对更早的对话生成一段简洁摘要,保留关键事实和未完成事项,替代原始消息。摘要可以异步生成,也可以在与用户交互的空闲时刷新。下面是一个上下文装配的伪代码:
def build_context(system_prompt: str, history: list, max_history_tokens: int = 800) -> list:
# 保留最近的消息,直到接近预算
recent = []
token_count = 0
for msg in reversed(history):
msg_tokens = estimate_tokens(msg["content"])
if token_count + msg_tokens > max_history_tokens:
break
recent.insert(0, msg)
token_count += msg_tokens
# 更早的消息交给摘要模块
older_messages = history[:-len(recent)] if recent else history
summary = ""
if older_messages:
summary = summarize(older_messages)
context = [{"role": "system", "content": system_prompt}]
if summary:
context.append({"role": "system", "content": f"历史对话摘要:{summary}"})
context.extend(recent)
return context
这个函数先倒序遍历历史消息,把最近消息放入recent列表,直到达到预算。更早的消息进入摘要模块,生成一段简短文本,作为额外的System消息插入。这样既控制了Token,又保留了关键背景。摘要模块可以使用一个单独的轻量模型,定期生成和缓存,避免每次请求都调用大模型。
对于工具调用结果,也可以采用类似裁剪思路。不要将完整JSON原样回传,而是提取与当前问题相关的字段,或者用模型先压缩工具结果。如果工具返回的是一长串列表,可以只保留前几条和总数信息,并在需要时通过分页参数再获取更多。
第四步:缓存公共前缀,利用服务端机制降低计费Token
许多大模型API对输入Token的计费是按请求中实际处理的Token数计算,但部分服务商提供了前缀缓存机制。例如当System Prompt或公共上下文长时间不变时,服务端会缓存这部分前缀,后续请求对缓存部分只收取较低费用或不重复计算。因此,应当尽量保持System Prompt和公共上下文稳定,避免每次请求动态拼接不同内容。
在工程实现上,可以将公共前缀放在消息列表最前面,保持字节级一致。不要在公共前缀中插入时间戳或用户ID等每次变化的字段,否则会导致缓存失效。如果需要动态信息,可以放在用户消息或额外消息中,而不是System Prompt里。这样既提高了缓存命中率,也降低了实际计费Token。
下表对比了不同优化手段对输入Token的影响,供参考:
| 优化手段 | 适用场景 | 预计Token降幅 |
|---|---|---|
| 精简System Prompt | 指令冗长、示例过多 | 20%-40% |
| 滑动窗口裁剪历史 | 多轮对话 | 30%-60% |
| 摘要替代早期历史 | 长对话且需要记忆 | 40%-70% |
| 工具结果字段裁剪 | Agent工具调用 | 15%-50% |
| 公共前缀缓存 | 固定System Prompt | 按服务商策略 |
不同的业务场景需要组合使用这些手段。例如一个在线教育辅导应用,System Prompt约900Token,单次会话平均20轮,采用精简System Prompt加滑动窗口摘要后,输入Token从平均7500降到2800,而回答质量通过用户满意度和知识点覆盖率评估没有下降。这个结果说明,上下文优化并不是牺牲质量,而是去掉无效信息。
第五步:建立Token预算与回归监控
优化不是一次性的。模型版本升级、业务逻辑变化、Prompt微调都会影响Token消耗。应当把Token预算纳入发布流程,类似性能测试。每次修改Prompt或上下文策略时,跑一批真实会话样本,统计输入Token中位数和P95值,并与基线对比。如果增长超过15%,需要给出解释或优化。
监控指标可以包括:平均输入Token、平均输出Token、System Prompt占比、历史消息平均长度、缓存命中率、回答首Token延迟。日志中保留每条请求的Token分解数据,方便事后分析。还可以设置告警,当单个请求输入Token超过阈值时通知开发者。
最后需要强调,减少Token不等于删除所有约束。模型需要足够的指令才能稳定输出,过度压缩可能导致格式错误、语气不一致或遗漏业务规则。优化时应该用评估集验证,关注关键指标,而不是只盯着Token数字。合理的目标是:在不降低任务完成率的前提下,把输入Token降低30%到60%。如果发现进一步压缩会导致质量下降,就说明已经接近该任务的有效上下文底线。
综合来看,解决Token消耗翻倍的核心不是某一个技巧,而是建立上下文的成本意识。把System Prompt当作需要付费的资产来维护,把每一条历史消息都视为有成本的数据,才能在功能迭代中持续控制消耗。当账单再次翻倍时,先检查上下文结构,往往比更换模型或削减功能更有效。