Token成本控制实战:语义缓存、上下文压缩与工具缓存
LLM 按 Token 计费,一个中等规模的 AI 应用每天可能产生数百万 Token 消耗。以 GPT-4o 为例,input 2.50/1M tokens、output 10.00/1M tokens,一次包含 30 轮对话的练习会话可能消耗 15K+ tokens,月活 1000 用户的场景下月成本轻松过万。
Token 消耗的三大来源:上下文(System Prompt + 历史消息)占 60-70%,工具调用结果注入占 15-20%,输出生成占 10-15%。控制成本的核心就是从这三个来源下手。
我在 nvc-guide里做了四层成本控制,今天详细聊聊。
第一层:语义缓存
传统缓存的困境
传统缓存基于精确匹配(hash(query) == hash(cached_query)),但 LLM 场景中,用户提问存在大量语义等价但字面不同的情况:
- "什么是 NVC?" vs "NVC 是什么意思?" vs "介绍一下 NVC"
- 传统缓存:3 次 LLM 调用
- 语义缓存:1 次 LLM 调用 + 2 次缓存命中
语义缓存原理
用户问题 → Embedding 向量化 → 与缓存库做相似度搜索
├── 相似度 ≥ 0.95 → 命中缓存,直接返回
└── 相似度 < 0.95 → 调用 LLM → 将结果存入缓存
关键技术参数:
| 参数 | 说明 | 推荐值 |
|---|---|---|
| 相似度阈值 | 控制缓存命中精度 | 0.93-0.97 |
| TTL | 缓存过期时间 | 7-30 天 |
| Embedding 模型 | 向量化模型 | text-embedding-3-small |
阈值选择的权衡
0.95 会不会太高?命中率只有 30-40% 值得吗?
阈值选择是命中率和准确率的权衡。阈值太高(>0.98)命中率极低;阈值太低(<0.90)容易误命中------"NVC 的四个步骤"和"NVC 的四个要素"向量相似度可能达到 0.90,但答案完全不同。
选择 0.95 的原因:知识问答类场景中,回答错误的代价远高于多调用一次 LLM。宁可少命中,也不能答错。
30-40% 的命中率意味着每天减少 300-400 次 LLM 调用,按每次 0.05 计算,每天节省 15-20,一个月 $450-600。
实现细节
项目用 pgvector 存储向量,直接用 SQL 做余弦相似度搜索:
sql
SELECT *, 1 - (query_embedding <=> CAST(:embedding AS vector)) AS score
FROM nvc_semantic_cache
WHERE 1 - (query_embedding <=> CAST(:embedding AS vector)) > :threshold
AND (expires_at IS NULL OR expires_at > NOW())
ORDER BY query_embedding <=> CAST(:embedding AS vector)
LIMIT :limit
几个设计决策:
场景白名单。只缓存知识问答类场景(7 种 Coach),不缓存评估和反思------这些任务的输出高度依赖用户的具体表现,缓存会导致评估结果失真。
异常降级。所有缓存操作都 try-catch 包裹,任何异常都降级为"缓存未命中",不影响主流程。
定时清理。每天凌晨 3 点清理过期条目,防止缓存表无限膨胀。
第二层:上下文压缩
Token 增长问题
多轮对话中,Token 数量持续增长。以 30 轮对话为例:System Prompt 1500 + 用户画像 300 + RAG 文档 2000 + 历史消息 30×400 = 15800 tokens。加上工具调用记录,实际消耗可能达到 20K+。
三层压缩方案
消息数 ≤ 20 → 直接返回(不压缩)
消息数 > 20 → 触发压缩
├── 早期消息(第 1 到 N-10 条)→ LLM 摘要(不超过 500 字)
├── 最近消息(最后 10 条)→ 保持不变
└── 摘要注入 System Prompt
为什么是 20 轮? 20 轮对话约消耗 6000-8000 tokens,加上 System Prompt 和用户画像,总计约 10K tokens,留出充足输出空间。
为什么保留 10 轮? NVC 练习需要足够的近期上下文来理解用户当前状态。10 轮能覆盖用户最近 2-3 个 NVC 步骤的讨论。
摘要注入位置:注入 System Prompt 而不是消息列表。System Prompt 在 LLM 的注意力机制中权重最高,保证摘要被优先关注。
降级截断
LLM 摘要调用失败时,降级为截断模式------取前 5 条消息,每条截断到 200 字符。保证即使 LLM 服务不可用,对话也能继续。
压缩效果
早期消息约 4000-6000 tokens → 摘要约 700 tokens,节省约 40% Token。
第三层:工具结果缓存
Agent 调用的工具(RAG 检索、用户档案查询等)结果在短时间内不会变化。在 Hook 链中拦截重复的工具调用,直接返回缓存结果。
分层 TTL
| 工具 | TTL | 理由 |
|---|---|---|
| dashboard_query | 5 分钟 | 仪表盘数据变化频繁 |
| profile_query | 10 分钟 | 用户档案较稳定 |
| rag_search | 30 分钟 | 知识库不常更新 |
| wiki_search | 30 分钟 | Wiki 内容不常变化 |
缓存 Key 策略
两种模式:
- 用户级缓存 :
{toolName}:{userId},同一用户短时间内结果相同(dashboard、profile) - 参数级缓存 :
{toolName}:{userId}:{paramHash},不同查询参数结果不同(rag、wiki)
为什么用 JVM 内存而不是 Redis?
工具缓存在 LLM 推理的关键路径上。JVM 内存访问 0.001ms vs Redis 网络往返 1-2ms,差距 1000 倍。且工具缓存是短 TTL、高频访问、允许重启后丢失的数据,完全适合内存缓存。
实现方式
在自建的 Hook 链中,CacheToolHook 位于 @Order(3):
beforeToolCall: 检查缓存
├── 命中 → 设置 skipReason → 返回 SKIP(跳过工具执行)
└── 未命中 → 返回 PROCEED(继续执行工具)
afterToolCall: 缓存结果
├── 成功结果 → 存入缓存
└── 错误结果 → 不缓存(避免缓存错误导致后续请求持续返回错误)
第四层:模型路由
不同任务对模型能力的要求不同。简单任务(如意图识别、格式化输出)用小模型处理,复杂任务(如 NVC 评估、反思分析)才用大模型。
项目通过 LlmProviderRegistry 支持多 Provider 动态管理:
| 场景 | ChatClient 变体 | 特点 |
|---|---|---|
| Agent 对话 | getDefaultChatClient() | 带工具 + 带记忆 |
| 出题/评分 | getPlainChatClient() | 无工具、无记忆 |
| 语音练习 | getVoiceChatClient() | 带工具、无记忆 |
评估和反思用 Plain ChatClient(无工具、无记忆),减少无关 Token 注入。
成本监控
通过 MetricsCollector 采集四项核心指标:
| 指标类型 | 采集内容 | 用途 |
|---|---|---|
| TOKEN | inputTokens, outputTokens, model | 成本核算 |
| COMPRESSION | beforeTokens, afterTokens, reductionPercent | 压缩效果分析 |
| TOOL_CALL | toolName, success, latencyMs | 工具效率分析 |
| LATENCY | latencyMs, phase | 性能监控 |
指标通过 Redis Stream 异步写入 PostgreSQL,不影响主流程性能。
整体效果
用户消息
│
▼
[语义缓存] --命中--> 直接返回(省 100% Token)
│ 未命中
▼
[上下文压缩] --超过 20 轮--> LLM 摘要(省 40%)
│
▼
[AgentLoop + 工具缓存] --缓存命中--> 跳过工具执行
│
▼
[LLM 调用 + 响应返回 + 语义缓存写入]
四层策略配合使用,Token 消耗降低约 60%,同时保证用户体验不受影响。
小结
Token 成本控制不是单一手段能解决的,需要多层配合。语义缓存解决"重复调用"问题,上下文压缩解决"上下文膨胀"问题,工具缓存解决"重复查询"问题,模型路由解决"大材小用"问题。四层叠加,才能在保证质量的前提下把成本压下来。