ARTICLE · INTELLIGENCE

战地情报 · 详情页

来自尧图项目组的一线实战观察与深度解析

LLM应用Token优化实战手册:从Prompt、RAG到多轮对话的成本治理

LLM应用Token优化实战手册:从Prompt、RAG到多轮对话的成本治理

很多开发者在第一次把大模型接入业务时,收到的成本账单往往会吓一跳:看起来只是让模型总结了一段对话、写了一封邮件,怎么一个月就花了上千元?如果再去细看推理日志,会发现真正的问题不在模型本身,而在我们送给模型的文本里。Prompt 里藏了几千个无用 Token,RAG 检索回来的文档有一大半和当前问题无关,多轮对话每次把历史原样堆进去,几轮之后直接顶到上下文窗口上限。

这篇文章的核心判断是:Token 优化不是“把 Prompt 写短一点”这种小技巧,而是 LLM 应用上线后必须建立的一项基础工程能力。它直接影响 API 成本、响应延迟、上下文窗口利用率,甚至影响模型输出质量。本文会从 Token 本质、Prompt 侧、RAG 侧、多轮对话侧、系统侧几个维度拆解优化思路,并提供可以在项目中直接落地的代码、配置和监控指标。

1. 为什么 Token 优化值得单独写一份手册

先看三个真实场景:

第一个场景,客服知识库问答。技术团队把几十份产品文档全部切块塞进向量数据库,每次用户提问,召回 Top 20 个片段拼进 Prompt。表面上检索很精准,但其中 12 个片段和问题毫无关系,白白浪费了 3000 多个 Token。

第二个场景,AI 写作助手。每次生成内容都在 Prompt 里附上完整用户资料、历史作品、平台要求,总共 4000 Token。但模型真正需要的,可能只是用户本次要写的主题、风格偏好和一条字数限制。

第三个场景,多轮对话应用。对话历史不做任何裁剪,用户问 20 轮之后,光历史记录就占了 8000 Token。此时要么触发窗口上限,不得不把最早一轮丢弃,要么每次请求都要支付高额输入费用,响应也越来越慢。

这三个场景有一个共同点:程序员并没有“主动设计”Token 消耗,只是默认把信息堆给模型。结果就是成本不可控、效果不可控、延迟不可控。

Token 优化手册的作用,就是把“随意堆信息”变成“存量预算 + 优先级分配”的工程过程。优化之后,同类场景通常能把输入 Token 降低 30% 到 60%,而输出质量不会下降,甚至因为噪声减少而更稳定。

2. 先搞懂 Token:它为什么是成本与性能的最小单位

2.1 Token 不是字数:中英文的差异

Token 是模型处理文本的最小粒度。一个中文词语、一个英文单词片段、一个标点符号,都可能对应一个或多个 Token。不同模型有不同分词器,同一个词在不同分词器里的 Token 数也可能不同。

这里有一个很容易被忽略的事实:英文中一个单词平均约 1.3 个 Token,而中文中一个汉字的表达力更高,通常一个汉字可能对应 1 到 2 个 Token,但一个中文词语往往需要 2 个以上 Token。这意味着,如果你用英文思维去估算中文文本的 Token 数,往往偏差很大。

具体到开发中,之前需要用tiktoken来精确计算:

# 文件路径:token_count_demo.py import tiktoken enc = tiktoken.get_encoding("cl100k_base") text = "这是一段用于统计 Token 数量的中文测试文本。" tokens = enc.encode(text) print(f"字符数: {len(text)}") print(f"Token 数: {len(tokens)}") print(f"Token 内容: {tokens}")

运行这段代码,你会看到 Token 数和字数并不是一一对应。这个差异直接影响了成本估算。

2.2 费用模型中的输入、输出与缓存

大部分商业大模型 API 按 Token 计费,而且输入 Token 和输出 Token 的价格往往不同。输入 Token 是发送给模型的全部文本,包括系统提示词、用户消息、历史记录、RAG 召回片段;输出 Token 是模型生成的内容。

成本公式可以用一句话概括:

单次调用费用 = 输入 Token 数 × 输入单价 + 输出 Token 数 × 输出单价

多轮对话中,这个费用是累积的。如果每次请求都带上全部历史,随着会话变长,每一轮新增请求的输入 Token 都会增加,成本曲线会越来越陡。

部分平台还提供 Prompt Caching 能力,即相同前缀在短时间内再次请求时可以享受价格折扣。这给了我们一个很重要的设计方向:尽量让请求的前缀保持稳定,而不是每次都重新拼一遍。这一点的工程实现,会在后文系统侧优化中展开。

2.3 隐形的 Token 消耗:系统提示词和输出格式约束

很多团队只盯着用户输入和 RAG 片段,却忽略了系统提示词。每次调用模型时,系统提示词都会完整发送。如果系统提示词是 1500 Token,每天调用 10000 次,仅系统提示词就要消耗 1500 万 Token,这可能比用户输入本身还要多。

同样的问题存在于输出格式约束。用 JSON 模式时,如果你在 Prompt 里写一份超长的 JSON 示例,这些示例也会全部进入输入 Token 计算。合理的做法是直接通过模型的 response_format 参数声明 JSON 输出,而不是在 Prompt 里堆砌大量示例。

3. Prompt 侧优化:在不牺牲效果的前提下砍掉冗余

3.1 识别三类无效信息

Prompt 优化不是“越短越好”,而是“每一段信息都要有明确用途”。无效信息通常分三类:

第一类:重复指令。同一句话在系统提示词里写了,又在用户消息里写了一遍。模型不会因为重复而提升理解,只会浪费 Token。

第二类:背景信息过载。为了让模型“更懂业务”,把完整的业务文档、产品介绍、内部规范全部塞进系统提示词。实际上,模型只需要与本次任务强相关的规则。

第三类:与任务无关的示例。示例能提升输出稳定性,但不需要每次都给 5 个。如果模型在同领域已经足够稳定,保留 1 到 2 个用例即可。

3.2 一个优化前后的完整对比

我们以“生成商品标题”为例。优化前的系统提示词可能是这样的:

你是一个电商平台的运营助手。我们平台叫云购商城,成立于2018年,目前有超过2000万注册用户,覆盖服装、数码、家居、美妆等多个品类。你的任务是帮助运营人员生成商品标题。商品标题要包含品牌词、品类词、核心卖点、人群词,同时要遵守广告法,不能使用“最”“第一”等极限词,也不能虚构产品功效。以下是更多说明:标题长度建议在20到30个字之间,要突出产品的差异化卖点,也要考虑搜索关键词覆盖。你可以参考平时的运营经验,结合用户提供的商品信息来完成标题生成。

这段系统提示词约 180 字,中文大约对应 150 到 200 个 Token。里面“平台成立年份”“注册用户数”“品类范围”都不是生成标题的必要信息。优化后可以压缩为:

你是电商运营助手。根据商品信息生成商品标题。 规则: 1. 长度 20 到 30 字; 2. 包含品牌词、品类词、核心卖点; 3. 遵守广告法,禁用极限词。 商品信息如下:

优化前 150 到 200 Token,优化后约 60 到 80 Token。单次调用省 100 Token,看起来不多,但如果每天调用几千次,省下的就是每月几百万 Token。

3.3 结构化提示词的额外收益

除了缩短长度,还要结构化。用清晰的分段和标记,让模型更快定位关键信息。例如用输入、要求、输出格式三个分段来组织 Prompt。这种做法的另一个好处是:模型会减少“猜测”成本,输出更稳定,从而降低因输出不符合格式而重试的次数。

4. RAG 场景的 Token 治理:从源头控制上下文体积

RAG 是 Token 消耗的大头。很多团队的 RAG 并没有做精细控制,结果就是:检索召回 20 个片段,每个片段 500 Token,光召回内容就 10000 Token。如果问题本身只需要其中 2 个片段,优化空间非常明显。

4.1 分块策略决定 Token 利用率

分块大小直接影响 Token 利用率。分块太小,单个片段语义不完整,模型理解需要更多片段,反而增加 Token;分块太大,片段中无关信息变多,单次调用可能超预算。

更通用的实践是按语义边界分块,而不是简单按固定字符数切。例如按段落、按标题、按 Markdown 结构切分,让每个块在语义上独立。

如果必须要固定长度切分,建议加入重叠区:

# 文件路径:chunk_splitter.py def split_text_by_chars(text, chunk_size=300, overlap=50): if len(text) <= chunk_size: return [text] chunks = [] start = 0 while start < len(text): end = start + chunk_size chunks.append(text[start:end]) if end >= len(text): break start = end - overlap return chunks text = "这里是一段很长的技术文档内容," * 50 chunks = split_text_by_chars(text, chunk_size=300, overlap=50) print(f"分块数量: {len(chunks)}") print(f"各块长度: {[len(c) for c in chunks]}")

固定长度分块的问题是,可能在句子中间截断,导致语义割裂。更好的方案是优先在段落边界切,再按 Token 上限合并。

4.2 检索数量控制:从“多多益善”到“够用就好”

召回 Top K 的数量不要盲设 10 或 20。它应该和分块大小、模型窗口、任务类型一起评估。一个简单的指导原则是:

召回的片段总 Token 数 ≤ 模型上下文窗口的 30% 到 40%

这样既给用户输入和模型输出留出空间,又不会因为召回过多引入噪声。检索结果不是越多越好。当 Top 20 里只有 3 个真正相关时,模型会被另外 17 个无关片段干扰,输出质量反而下降。

4.3 召回后的后处理与压缩

很多团队在向量检索之后,直接把召回片段拼接进 Prompt。这忽略了一个关键环节:后处理。

可以做三件事:

第一,重排序。向量检索只解决语义相似,不保证答案相关性。可以用更精细的模型或规则二次排序,把最相关的片段放在最前面。

第二,去除重复。多个片段可能包含相同内容。去重能直接省 Token。

第三,判断是否需要召回。有些问题不需要 RAG。比如用户只是说“帮我解释一下什么是多线程”。这时候直接让模型用自身知识回答即可,不必进入检索环节。给整个流程加一层“是否需要检索”的判断,能省下大量不必要的 Token。

4.4 完整示例:可控 Token 的 RAG 查询函数

# 文件路径:rag_query.py MAX_CONTEXT_TOKENS = 2000 def build_rag_prompt(query, retrieved_docs, system_prompt): used_tokens = count_tokens(system_prompt) + count_tokens(query) context_parts = [] for doc in retrieved_docs: doc_tokens = count_tokens(doc["content"]) if used_tokens + doc_tokens > MAX_CONTEXT_TOKENS: break context_parts.append(doc["content"]) used_tokens += doc_tokens context = "\n\n".join(context_parts) return f"{system_prompt}\n\n背景资料:\n{context}\n\n用户问题:{query}"

这个函数在把召回片段拼接进 Prompt 之前,先做 Token 预算检查。超出预算的片段直接丢弃,而不是一股脑全塞进去。

5. 对话历史与长上下文管理:如何让 Token 在多轮会话中不失控

5.1 滑动窗口:只保留最近 N 轮

最简单也最有效的方法,是只保留最近几轮对话。例如只保留最近 6 轮,超过的旧消息直接丢弃。适用于大多数轻量问答场景。但它的问题是,模型可能会忘记用户早期提到的重要信息,比如用户的姓名、偏好、某项前提条件。

5.2 摘要压缩:用少量 Token 保留关键信息

更稳妥的方案是分层管理:早期对话用摘要保存在上下文里,最近几轮用完整文本。具体实现可以这样:

  • 每 5 轮生成一次已有对话的摘要;
  • 新请求的 Prompt = 摘要 + 最近 3 轮完整对话;
  • 摘要只保留事实性信息,不保留寒暄和重复内容。

核心判断是:历史对话的价值密度并不均匀。90% 的历史内容是寒暄、确认、重复表达,真正对后续回答有用的可能只有几个事实。摘要的作用就是把这些事实提取出来。

5.3 关键信息结构化存储

比摘要更进一步,是在业务层维护一个“用户状态”对象。例如:

{ "user_id": "12345", "preferences": { "language": "中文", "style": "简洁", "city": "上海" }, "facts_mentioned": [ "在准备面试 Java 后端岗位", "有 3 年开发经验" ], "last_topic": "Spring Boot 事务失效排查" }

每次请求只把这份结构化信息注入系统提示词,而不是把完整聊天记录都发给模型。这种做法在客服、教育、写作助手等场景效果很好。

5.4 对话管理器示例

下面这段代码演示了如何实现“摘要 + 最近 N 轮”的混合策略:

# 文件路径:dialog_manager.py class DialogManager: def __init__(self, max_keep_rounds: int = 4, summary_model=None): self.messages = [] self.summary = "" self.max_keep_rounds = max_keep_rounds self.summary_model = summary_model def add_message(self, role: str, content: str): self.messages.append({"role": role, "content": content}) if len(self.messages) > self.max_keep_rounds * 2: self.compress() def compress(self): older = self.messages[:self.max_keep_rounds * 2] self.messages = self.messages[self.max_keep_rounds * 2:] if self.summary_model: new_summary = self.summary_model.compress(self.summary, older) self.summary = new_summary def build_messages(self): if self.summary: summary_msg = { "role": "system", "content": f"历史对话摘要:{self.summary}" } return [summary_msg] + self.messages return self.messages

这个类虽然简化了真实场景中摘要生成、并发写入等逻辑,但已经把 Token 优化的核心思想体现出来了:不让历史无限膨胀,用摘要替代原始内容,用最近消息保留细节。

6. 结构化输出与工具调用:用格式约束减少无效生成

6.1 输出格式控制:节省重复尝试成本

如果对模型输出没有硬性约束,它可能会在结果前后加上解释、补充、抱歉话术,或偶尔不按格式返回。这些情况可能不会显著增加单轮 Token,但会导致下游解析失败、脚本重试,整体成本翻倍。

更合理的做法是尽量使用模型的响应格式参数。例如 OpenAI 兼容接口的 JSON 输出模式,或 vLLM 部署时的 guided decoding。即使不用这些高级功能,也要在 Prompt 中明确输出结构,例如:

请严格输出 JSON,格式如下: {"title": "商品标题", "keywords": ["词1", "词2"], "reason": "创意依据"}

6.2 工具调用中减小参数描述

在 Agent 场景中,工具定义的 JSON Schema 也会占用 Token。工具参数描述写得越长,每次调用时发送的 Token 就越多。一种常见的优化是:

  • 只描述必要参数,不填写冗余说明字段;
  • 把参数的示例值尽量缩短;
  • 多个工具共用的参数描述,统一用“见系统提示词”来引用,不重复展开。

这里有一个权衡:参数描述太短可能导致模型理解不准确,导致功能调用错误和重试。建议先以 1 到 2 个真实调用作为验证,再逐步精简。

7. 系统层面的 Token 优化:缓存、批处理与模型选择

7.1 Prompt Caching:让前缀变成复用资产

部分大模型平台支持 prompt caching,即相同前置文本在一定时间内再次提交,不再全额计费。要利用这一点,技术上要满足“前缀稳定性”:

第一,系统提示词放在最前面。把会经常变化的用户输入放在后面,固定指令放在前面,保证公共前缀完整。

第二,尽量避免在系统提示词中拼接动态信息。动态内容放在用户消息或独立的“动态上下文”位置,否则每次前缀都不同,缓存无法命中。

第三,相同前缀的文本要完全一致。任何字符差异都会导致缓存失效。

7.2 批处理与业务聚合

如果大量请求是独立的、不需要实时响应的,可以打包成批量任务提交。批量接口通常有更低价格,还能统一控制并发。更常用的优化是在同一次请求内完成多个子任务,例如一次让模型完成“标题 + 摘要 + 关键词”三项生成,而不是调用三次模型。

7.3 模型选择:小模型优先

不是所有任务都需要旗舰模型的能力。简单分类、信息抽取、格式转换等任务,用更小的模型可能效果差异不大,但 Token 单价明显更便宜,响应也更快。

判断标准是:跑一批代表性用例,对比大模型和小模型在相同 Prompt 下的输出通过率。这个验证过程虽然耗时,但对长期成本影响巨大。

7.4 模型参数的影响

还有一个容易忽略的环节:温度、top_p 等采样参数本身不消耗额外 Token,但会影响重试率。采样参数设置过于随机,输出不稳定,重试次数就会增加,Token 消耗自然上升。在任务允许时,适当降低温度能减少格式错误和逻辑跳跃。

8. 成本估算与监控:把 Token 优化变成可量化指标

8.1 前置估算:上线前就能算出成本

在实际调用前,可以做一次静态估算。假设每天调用 10000 次,平均每次输入 2000 Token,输出 300 Token,那每天约消耗 2000 万输入 Token 和 300 万输出 Token。按月计算,乘以对应单价,就能得到一个可接受的成本区间。

8.2 运行时监控:记录每一笔 Token 消耗

在生产环境中,需要在应用层记录每次请求的输入和输出 Token 数,并写入日志或指标系统:

# 文件路径:token_monitor.py import time class TokenUsageRecord: def __init__(self): self.records = [] def log_request(self, api_name, prompt_tokens, completion_tokens, latency_ms): self.records.append({ "timestamp": time.time(), "api_name": api_name, "prompt_tokens": prompt_tokens, "completion_tokens": completion_tokens, "total_tokens": prompt_tokens + completion_tokens, "latency_ms": latency_ms }) def summary(self): total_prompt = sum(r["prompt_tokens"] for r in self.records) total_completion = sum(r["completion_tokens"] for r in self.records) return { "total_requests": len(self.records), "total_prompt_tokens": total_prompt, "total_completion_tokens": total_completion, "total_tokens": total_prompt + total_completion, }

更进一步的方案是,为每一次业务请求设置 Token 预算,预算超限时触发告警。这样做的好处是,你不再等到月底看账单才发现成本失控,而是在当天就能看到异常。

8.3 建立基线指标

建议为项目建立三个基线指标:

指标含义优化目标
单次请求平均输入 Token每次调用发送给模型的 Token 数持续下降或稳定
单次请求平均输出 Token模型生成的 Token 数匹配任务需求,不冗余
Token 有效率真正被下游使用的 Token 占总 Token 比例越高越好

“Token 有效率”是很多人不关注但很有价值的指标。一个 RAG 请求召回了 8000 Token,但模型真正参考的只有其中 2000 Token,Token 有效率就是 25%。通过调整召回数量和重排序策略,Token 有效率可以提升到 50% 以上。

9. Token 优化的常见误区与排查

9.1 误区一:把“少写 Prompt”等同于 Token 优化

缩减 Prompt 长度必须建立在“信息不缺失”的基础上。如果为了省 Token 把关键约束删掉,导致模型输出不合格、需要重写多次,整体成本反而更高。Token 优化的真正原则是:减少无价值信息,保留高价值信息。

9.2 误区二:只优化第一次请求,忽视多轮累积

很多团队只关注首轮问答时的 Token 数,却没有处理第二轮、第五轮、第十轮的累积增长。真正要控制的是“整个会话生命周期内的总 Token 消耗”,至少要做到历史裁剪或摘要化。

9.3 误区三:盲目把 RAG 召回数量降到极低

召回数量太少会导致模型缺少必要信息,输出只能靠模型固有知识。这个问题比 Token 浪费更严重。不要只看 Token 指标,还要看回答质量和准确性。

9.4 常见问题排查表

问题现象可能原因排查方式解决方案
输入 Token 异常高历史消息未裁剪检查构建 messages 的逻辑,查看每条消息大小引入对话管理器和摘要压缩
RAG 请求超时召回片段过多,模型处理慢打印召回片段的 Token 数限制召回数量,增加后处理
输出经常不符合格式约束描述不够明确检查 Prompt 中是否有输出格式示例使用 JSON 输出模式或精简示例
Prompt Caching 不生效前缀不稳定对比每次请求的前缀是否完全一致固定系统提示词,动态内容后移
成本突增某类任务输出过长或重试次数多查看输出 Token 和重试记录降低温度、增加输出长度上限、优化示例
模型回答质量下降过度压缩上下文导致信息缺失对比压缩前后的回答增加摘要保留关键事实,或调整裁剪窗口

10. 最佳实践清单与工程建议

把 Token 优化落地到团队项目,建议按以下顺序推进:

第一步,建立 Token 观测。在没有数据之前不要做任何优化。先对每一次模型调用记录输入、输出 Token 数,按业务场景聚合出成本分布。这既能为优化提供方向,也能在优化后量化收益。

第二步,给 Prompt 做一次体检。检查系统提示词是否有冗余背景、重复指令、过长示例。重点看公共前缀,因为它影响缓存命中率。

第三步,改造 RAG 链路。调整分块策略,加入召回数量控制和 Token 预算检查,增加必要的后处理和重排序。

第四步,处理多轮对话。引入滑动窗口和摘要压缩,至少做到“最近 N 轮完整保留,更早历史摘要化”。如果业务有明确状态对象,优先用结构化信息代替原始会话记录。

第五步,系统侧配置。合理规划 Prompt 前缀顺序,评估小模型替代大型模型的可能性,为不同任务选择合适的模型策略。

除了流程,这里还想强调三个工程习惯:

  • 把系统提示词和用户输入分开管理,不要拼成一个超长字符串再发给模型;
  • 把 Token 消耗记录和业务日志放在一起,出现问题能快速定位到具体业务场景;
  • 每次重大优化前,保留一组固定测试用例,用来对比优化前后的输出是否退化。

关于安全与合规的提醒:在生产环境中接入大模型,不要随意在 Prompt 中拼接来源不明的内容,尤其是外部输入的文本,可能包含注入指令。RAG 检索回来的文档内容也应该做过滤和脱敏,避免把敏感信息直接暴露给模型或写入日志。对涉及权限、支付、数据库变更等高风险操作,必须在应用层做鉴权和人工复核,不能单纯依赖模型的判断。

11. 总结与下一步实践

Token 优化并不是一个“一次性做完”的工作。当业务增长、Prompt 内容变化、基础模型升级时,Token 消耗结构都会随之变化,需要持续观测和调整。

这篇文章真正讲清楚了几件事:

  • Token 是成本与性能的最小单位,它与字数不是一回事;
  • Prompt 优化的关键不是越短越好,而是保留有用信息、去掉噪声;
  • RAG 场景要同时控制分块大小、召回数量和 Token 预算;
  • 多轮对话必须管理历史,不让会话无限膨胀;
  • 系统层可以通过 Prompt Caching、批处理、小模型来降低单位成本;
  • 监控指标和成本基线是对优化效果做评判的前提。

如果你现在正负责一个 LLM 应用,建议先花半小时做一件最简单的事:写一段脚本,统计最近 1000 次线上请求的平均输入 Token、输出 Token 和顶部分布。这个数据会让你立刻知道最该优化的是 Prompt、RAG 还是对话历史。

之后可以继续深入几个方向:更精细的检索重排序策略、摘要模型的选择与缓存、面向业务场景的语料结构化。这些每一步都能继续压降 Token 消耗,但每一步之前,都要先确认优化没有影响回答质量。

Token 优化的最后一条准则是:省 Token 必须为效果服务。当成本优化和输出质量冲突时,优先保证质量,再想办法通过工程手段降低成本。

RELATED READING

延伸阅读

更多一线实战笔记与深度复盘,助您持续精进