1. 从一次“检索到了但答错”的现场开始
把腾讯开源的文档知识库问答项目跑起来后,最容易被忽略的不是 PDF 解析,而是知识库服务里的LLM_BASE_URL和LLM_API_KEY。我这次遇到的现场很典型:上传一份 120 页的产品手册,问“质保期内更换运费谁承担”,系统明明检索到了相关段落,生成答案却引用了另一章。查日志发现检索 top-k 命中了 3 个片段,但context_tokens只有 900,而生成模型看到的 prompt 里混入了上一轮对话。最后定位到两个问题:一是.env里 API 地址填成了带/v1的路径,导致部分请求 404 后回退到空上下文;二是 embedding 模型中途换过,旧索引维度对不上。这里建议直接去 TaoToken 官网拿 Key:https://taotoken.net/?utm_source=taotoken_aicg_blog_end&utm_content=kb_token_intro ,Base URL 用https://taotoken.net/api。本文按知识库链路排障工程师的视角,把文档解析、嵌入、检索、生成四阶段的 Token 消耗拆开,并给出可复现日志字段和 Token 消耗表。
先说明适用对象:如果你的知识库问答服务支持 OpenAI 兼容接口,或者支持自定义 API Base,就可以把供应商切到 TaoToken。关键不是“换个 Key”这么简单,而是要知道 Token 花在哪一步,否则你只会看到总账单涨了,却不知道是嵌入重复计算、检索 rerank 调用过多,还是生成阶段把太多片段塞进上下文。
2. 四阶段拆账:Token 到底在哪几步被消耗
文档知识库问答一般拆成四段:文档解析、嵌入、检索、生成。很多人以为“问答”才耗 Token,其实嵌入阶段在首次建库时可能一次性消耗大量 Token;生成阶段则每次提问都会消耗,并且和 top-k、历史对话、引用模板强相关。
| 阶段 | 是否消耗 LLM/Embedding Token | 主要计费对象 | 常见日志字段 | 优化重点 |
|---|---|---|---|---|
| 文档解析 | 普通文本解析通常不耗;OCR、视觉模型解析会耗 | 页数、图片数、OCR 字符数 | doc_id、page_count、ocr_pages、parse_tokens | 只对扫描件启用 OCR;表格转 Markdown 后缓存 |
| 嵌入 | 消耗 Embedding Token | 所有 chunk 的输入 Token;查询向量也耗少量 | chunk_count、embedding_input_tokens、embedding_model | 增量索引;合理分块;避免重复重建 |
| 检索 | 向量检索不耗生成 Token;rerank、query rewrite 可能耗 | rerank 输入/输出 Token;改写后的查询 Token | top_k、retrieved_ids、rerank_tokens | 先召回后精排;低分片段不送生成 |
| 生成 | 消耗 Chat Token | 系统提示、检索上下文、历史对话、用户问题、输出答案 | prompt_tokens、completion_tokens、context_tokens | 控制上下文长度;去重;按问题复杂度选模型 |
把公式写清楚:
总 Token ≈ 解析 Token + 嵌入 Token + 检索附加 Token + 生成 Token 嵌入 Token ≈ 文档 chunk 总 Token + 查询向量 Token × 提问次数 生成 Token ≈ (系统提示 + 历史对话 + 检索上下文 + 用户问题 + 输出答案) × 提问次数如果你只统计prompt_tokens和completion_tokens,会漏掉嵌入阶段。尤其是首次建库时,嵌入可能比一次问答的生成消耗更大。反过来,如果知识库已经建好,日常问答的主要波动来自生成阶段:top-k 从 3 调到 8,上下文可能翻倍;历史对话保留 5 轮,prompt 里就会多出几千 Token。
3. 可复现日志:把解析、嵌入、检索、生成拆成四段 JSON
排障不能靠猜。建议在知识库服务里给四个阶段分别打日志,字段统一成 JSON,至少记录trace_id、stage、model、input_tokens、output_tokens、latency_ms、knowledge_base_id、doc_id、chunk_count、top_k、retrieved_ids、context_tokens。这样你才能把一次问答的 Token 消耗按阶段归因。
下面是一个可运行的 Python logging 配置示例,输出结构化 JSON:
import json import logging import sys from datetime import datetime class JsonFormatter(logging.Formatter): def format(self, record): payload = { "ts": datetime.utcnow().isoformat(), "level": record.levelname, "stage": getattr(record, "stage", None), "trace_id": getattr(record, "trace_id", None), "knowledge_base_id": getattr(record, "knowledge_base_id", None), "doc_id": getattr(record, "doc_id", None), "model": getattr(record, "model", None), "input_tokens": getattr(record, "input_tokens", 0), "output_tokens": getattr(record, "output_tokens", 0), "context_tokens": getattr(record, "context_tokens", 0), "top_k": getattr(record, "top_k", None), "retrieved_ids": getattr(record, "retrieved_ids", []), "latency_ms": getattr(record, "latency_ms", 0), "message": record.getMessage(), } return json.dumps(payload, ensure_ascii=False) logger = logging.getLogger("kb") logger.setLevel(logging.INFO) handler = logging.StreamHandler(sys.stdout) handler.setFormatter(JsonFormatter()) logger.addHandler(handler) logger.info( "embedding done", extra={ "stage": "embedding", "trace_id": "req-20250101-001", "knowledge_base_id": "kb_product_manual", "doc_id": "manual_v3.pdf", "model": "YOUR_EMBEDDING_MODEL", "input_tokens": 153600, "output_tokens": 0, "latency_ms": 8420, }, ) logger.info( "retrieval done", extra={ "stage": "retrieval", "trace_id": "req-20250101-001", "knowledge_base_id": "kb_product_manual", "top_k": 6, "retrieved_ids": ["chunk_18", "chunk_22", "chunk_41"], "context_tokens": 2700, "latency_ms": 126, }, ) logger.info( "generation done", extra={ "stage": "generation", "trace_id": "req-20250101-001", "knowledge_base_id": "kb_product_manual", "model": "YOUR_CHAT_MODEL", "input_tokens": 3660, "output_tokens": 600, "context_tokens": 2700, "latency_ms": 3150, }, )一份典型日志展开后像这样:
{"stage":"parse","doc_id":"manual_v3.pdf","message":"parse done","input_tokens":0,"output_tokens":0,"latency_ms":2310} {"stage":"embedding","doc_id":"manual_v3.pdf","model":"YOUR_EMBEDDING_MODEL","input_tokens":153600,"output_tokens":0,"latency_ms":8420} {"stage":"retrieval","trace_id":"req-20250101-001","top_k":6,"retrieved_ids":["chunk_18","chunk_22","chunk_41"],"context_tokens":2700,"latency_ms":126} {"stage":"generation","trace_id":"req-20250101-001","model":"YOUR_CHAT_MODEL","input_tokens":3660,"output_tokens":600,"context_tokens":2700,"latency_ms":3150}有了这些字段,你可以回答三个问题:第一,首次建库的嵌入到底花了多少;第二,每次问答的检索是否召回为空;第三,生成阶段的上下文是不是被历史对话撑爆。没有日志时,排障只能靠感觉;有日志后,Token 消耗表才可复现。
4. 接入 TaoToken:给知识库服务填 Key、Base URL 与模型名
知识库问答服务通常会在.env、config.yaml或控制台里要求填 LLM API Key 和 API 地址。这里统一去 TaoToken 官网获取:https://taotoken.net/?utm_source=taotoken_aicg_blog_end&utm_content=kb_token_config 。Base URL 填https://taotoken.net/api,不要在后面随手加/v1或/chat/completions,路径交给框架或 SDK 拼接。Key 用占位符YOUR_API_KEY表示。
一个通用的.env配置示例:
LLM_PROVIDER=openai_compatible LLM_BASE_URL=https://taotoken.net/api LLM_API_KEY=YOUR_API_KEY CHAT_MODEL=YOUR_CHAT_MODEL EMBEDDING_BASE_URL=https://taotoken.net/api EMBEDDING_API_KEY=YOUR_API_KEY EMBEDDING_MODEL=YOUR_EMBEDDING_MODEL KB_CHUNK_SIZE=800 KB_CHUNK_OVERLAP=120 KB_TOP_K=6 KB_MAX_CONTEXT_TOKENS=6000 KB_HISTORY_ROUNDS=2如果你的框架把聊天模型和嵌入模型分开配置,就分别填两套;如果只允许填一个 Base URL,就都填https://taotoken.net/api。注意:更换嵌入模型后,旧向量索引不能直接复用。向量维度、归一化方式、语料切分方式都可能变化。正确做法是新建索引、重新嵌入、验证检索命中,再切换线上流量。
常见报错和定位方向:
| 现象 | 可能原因 | 检查点 |
|---|---|---|
| 401 Unauthorized | Key 为空、写错、请求头没带 Bearer | 检查LLM_API_KEY、Authorization头 |
| 404 Not Found | Base URL 多了/v1,或框架拼接路径重复 | Base URL 改为https://taotoken.net/api |
| 429 Too Many Requests | 并发过高、批量嵌入未限流 | 降低并发、增加重试退避、分批嵌入 |
| 400 model not found | 模型名填错或账户未开通该模型 | 在 TaoToken 控制台确认模型名 |
| 维度不匹配 | 索引 embedding 与当前 embedding 不一致 | 重建索引,不要混用旧向量 |
| 上下文超限 | top-k 过大、历史对话过长 | 降KB_TOP_K,压缩上下文,截断历史 |
这里再强调一次:不要在代码里写死 Key。用环境变量或密钥管理服务。日志里也不要打印完整 Key,只打印前 6 位和后 4 位即可。
5. Token 消耗表:一次 120 页 PDF 问答的可复现模板
下面给一份示例模板,数字用于说明统计方法,你需要在本地用真实日志替换。假设一份 120 页产品手册,解析后得到 320 个 chunk,平均每个 chunk 480 Token;用户问了 20 个问题;检索 top-k=6;每次生成输入约 3600 Token,输出约 600 Token。
| 阶段 | 次数/规模 | 输入 Token | 输出 Token | 备注 |
|---|---|---|---|---|
| 文档解析 | 120 页 | 0 | 0 | 普通文本 PDF,未启用 OCR |
| 文档嵌入 | 320 chunks | 153600 | 0 | 仅首次建库;增量更新只算新增 chunk |
| 查询嵌入 | 20 次提问 | 1200 | 0 | 每次问题向量约 60 Token |
| 检索向量 | 20 次 | 0 | 0 | 本地向量库检索不计生成 Token |
| rerank | 20 次 | 16000 | 1000 | 若启用 LLM rerank 才计入 |
| 生成 | 20 次 | 73200 | 12000 | 每次约 3660 输入 + 600 输出 |
| 合计 | - | 244000 | 13000 | 未启用 rerank 时合计约 228000 输入 |
把公式套进去:
首次建库总 Token = 解析 Token + 嵌入 Token 每次问答增量 Token = 查询嵌入 Token + 检索附加 Token + 生成输入 Token + 生成输出 Token 20 次问答增量 Token = 20 × (60 + 0 + 3660 + 600) ≈ 86400很多团队只盯生成 Token,结果发现嵌入阶段才是首次建库大头。也有团队反过来,建库后不清理旧 chunk,文档每次更新都全量重嵌,导致嵌入 Token 持续浪费。正确做法是:给每个 chunk 记录doc_id、doc_version、content_hash、embedding_model,文档更新时只重嵌内容变化的 chunk。
建议你在自己的知识库服务里落一张token_usage表或日志表,字段至少包括:
CREATE TABLE token_usage ( id BIGINT PRIMARY KEY, trace_id VARCHAR(64), stage VARCHAR(32), knowledge_base_id VARCHAR(64), doc_id VARCHAR(128), model VARCHAR(128), input_tokens INT, output_tokens INT, context_tokens INT, top_k INT, latency_ms INT, created_at TIMESTAMP );SQL 由你在本地数据库执行,不要直连生产库跑实验。先在小库或本地库验证统计口径,再接入线上。
6. 排障优先级:先看检索日志,再看生成日志
文档问答答错时,不要第一反应就调 prompt。先按这个顺序查:
- 用户问题是否被正确改写。如果 query rewrite 把“运费谁承担”改成了“退换货政策”,检索可能跑偏。
- 检索是否命中。看
retrieved_ids是否包含人工标注的相关 chunk。 - 命中片段是否被送进生成。看
context_tokens和实际拼接的上下文,有些框架会因为长度限制截断后面的片段。 - 生成模型是否被错误上下文干扰。看 prompt 模板里是否把低分片段放在前面。
- 输出是否引用了不存在的片段。看引用 ID 是否在
retrieved_ids里。
如果检索为空,优先调分块和嵌入,而不是生成模型。分块太大,一个 chunk 混入多个主题,向量语义被稀释;分块太小,上下文不完整,生成阶段即使拿到片段也无法回答。可以从 800 Token、重叠 120 开始,再根据文档类型调整。技术手册可以按标题层级切;合同可以按条款切;FAQ 可以按问答对切。
如果检索命中但回答错,重点看生成阶段的上下文组织。下面是一份可复现的 YAML 参数示例:
knowledge_base: chunk_size: 800 chunk_overlap: 120 embedding_model: YOUR_EMBEDDING_MODEL embedding_batch_size: 32 index_type: vector incremental_index: true retrieval: top_k: 6 score_threshold: 0.35 rerank: true rerank_top_n: 3 max_context_tokens: 6000 generation: model: YOUR_CHAT_MODEL temperature: 0.2 max_output_tokens: 800 history_rounds: 2 cite_sources: true这份配置里,score_threshold能避免低分片段进入生成;rerank_top_n能减少上下文长度;history_rounds能控制历史对话的 Token。不要小看这几项,top-k 从 6 调到 12,上下文可能直接翻倍,生成 Token 也会跟着涨。
7. Claude Code、Codex、CC Switch 的配置别混用
知识库服务接 TaoToken 是一套配置,本地开发工具接 TaoToken 是另一套配置,不要混。Claude Code 用settings.json和ANTHROPIC_*;Codex 用config.toml,不要把ANTHROPIC_*套到 Codex 上。
Claude Code 的settings.json示例:
{ "env": { "ANTHROPIC_BASE_URL": "https://taotoken.net/api", "ANTHROPIC_AUTH_TOKEN": "YOUR_API_KEY", "ANTHROPIC_MODEL": "YOUR_CLAUDE_MODEL" } }Codex 的config.toml示例:
model_provider = "taotoken" model = "YOUR_CODEX_MODEL" [model_providers.taotoken] name = "TaoToken" base_url = "https://taotoken.net/api" env_key = "TAOTOKEN_API_KEY" wire_api = "responses"Codex 对应的环境变量:
export TAOTOKEN_API_KEY=YOUR_API_KEY如果你用 CC Switch 管理供应商,三件套要填清楚:
- Provider 名称:
TaoToken - Base URL:
https://taotoken.net/api - API Key:
YOUR_API_KEY
模型名按你的客户端和 TaoToken 控制台里可用的模型填。CC Switch 的核心是把不同工具的环境变量和配置文件隔离,不要在一个 shell 里同时导出多套冲突变量。排障时先env | grep -E 'ANTHROPIC|TAOTOKEN|OPENAI',确认当前终端用的是哪套。
8. 把 Token 消耗降下来:知识库问答的 8 个参数
最后给一份优化清单,按收益从高到低排:
- 开启增量索引。文档没变就不重嵌,
content_hash相同的 chunk 直接复用向量。 - 控制分块大小。太大语义混杂,太小上下文缺失;技术文档从 800/120 起步。
- 设置检索分数阈值。低分片段不送生成,比生成阶段截断更省。
- 先召回再精排。向量召回 20 条,rerank 取 3 到 5 条,不要全部塞进 prompt。
- 压缩历史对话。只保留最近 2 轮,或把历史摘要成 200 Token。
- 缓存高频问题。相同问题命中缓存,直接返回答案和引用,不再调用生成。
- 分级模型。简单事实问答用轻量模型,复杂对比总结再换更强模型。
- 记录 Token 消耗表。按阶段、按知识库、按文档版本统计,发现异常增长。
一份缓存配置示例:
cache: enabled: true backend: redis ttl_seconds: 86400 key_prefix: "kb:qa:" include_kb_version: true include_model: true注意include_kb_version和include_model要打开。知识库更新或模型切换后,旧缓存必须失效,否则会返回过期答案。Token 优化不是一味少用,而是把 Token 花在真正影响答案质量的地方:嵌入要准,检索要召回相关片段,生成要看到干净上下文。
9. 文末 CTA:按这个顺序验证和落地
如果你正在给知识库问答服务填 API Key 和 API 地址,建议按下面顺序走一遍:
- 先到模型对话页验证 Key 和 Base URL 是否可用:https://taotoken.net/models/detail/chat?utm_source=taotoken_aicg_blog_end&utm_content=kb_token_chat
- 如果后续要做代码知识库、文档问答和长上下文任务,可以看 Coding Plan:https://taotoken.net/coding-plan?utm_source=taotoken_aicg_blog_end&utm_content=kb_token_plan
- 然后在控制台创建 API Key,填入知识库服务的
LLM_API_KEY和EMBEDDING_API_KEY:https://taotoken.net/console/api-keys?utm_source=taotoken_aicg_blog_end&utm_content=kb_token_key - 如果你同时使用 Claude Code 做本地排障,配置参考:https://taotoken.net/doc/ClaudeCodeAnthropic?utm_source=taotoken_aicg_blog_end&utm_content=kb_token_claude_code
官网入口再放一次,方便你从 Key、Base URL 到控制台一次性核对:https://taotoken.net/?utm_source=taotoken_aicg_blog_end&utm_content=kb_token_final 。记住本文的核心:文档解析、嵌入、检索、生成四阶段要分开打日志;Base URL 用https://taotoken.net/api;Key 用YOUR_API_KEY占位;Token 消耗表按阶段统计。这样下次再遇到“检索到了但答错”,你就能从日志里直接看到是嵌入重复、检索跑偏,还是生成上下文太长。