1. 文档变知识库问答:真正花 Token 的是 embedding 与 chat 两处
先说结论:把一堆 PDF、Markdown、Word 变成「能问答的知识库」,Token 并不只花在问答那一下,而是花在两处。TaoToken(https://taotoken.net/?utm_source=taotoken_aicg_blog_end&utm_content=kb_qa_intro)作为统一的模型调用入口,把这两处调用收敛到同一组 Key 和同一个 Base URL 上,配起来省事,但也正因为「一个 Key 打天下」,两阶段的账很容易糊在一起。
腾讯开源的那个「上传文档 → 自动切片 → 向量化 → 检索问答」项目最近被反复提起。它的上手路径很短:部署服务、上传文档、在页面上直接提问。但对盯着调用成本的人来说,真正值得拆开看的是中间那条链路——文档入库时,每一个切片都要过一次 embedding 模型;用户提问时,检索命中的若干切片会被拼进 Prompt,再过一次 chat 模型。前者只有输入 Token,后者输入和输出都计费,两阶段的量级、触发频率、优化手段完全不同。
不少人在这一步会卡住:服务要求填 LLM API Key 和 API 地址,填完却发现检索没结果,或者答案答非所问;回头查用量,也说不清是哪一阶段烧掉的。本文按「模型调用成本观察者」的视角,把 embedding 与 chat 两阶段拆开,给出可复现的请求样本、可落地的 Token 统计表,以及同一套 Key 在 Claude Code、Codex、CC Switch 里的配置写法。
2. Key 与 API 地址填在哪:知识库问答服务的两处配置落点
知识库问答类服务通常只有一个「模型配置」入口,但它内部至少要构造两个客户端:一个 embedding 客户端、一个 chat 客户端。两者可以共用同一个 Key,也可以分开管理。
- 拿 Key 的入口在官网控制台:从 https://taotoken.net/?utm_source=taotoken_aicg_blog_end&utm_content=kb_qa_console 进入后创建 API Key;
- 也可以直接打开 https://taotoken.net/console/api-keys?utm_source=taotoken_aicg_blog_end&utm_content=kb_qa_apikey 创建与轮换 Key;
- Base URL 统一填
https://taotoken.net/api(工具配置,不带 UTM 参数); - Key 占位符在代码里写成
YOUR_API_KEY,不要提交进 Git。
用.env把两阶段配置分开写,便于后续做用量归因:
# .env TAOTOKEN_BASE_URL=https://taotoken.net/api TAOTOKEN_API_KEY=YOUR_API_KEY # 两个阶段即使共用 Key,也建议分两个变量名,方便以后拆 Key EMBEDDING_MODEL=text-embedding-3-large CHAT_MODEL=gpt-4o-miniimport os from openai import OpenAI client = OpenAI( api_key=os.environ["TAOTOKEN_API_KEY"], base_url=os.environ["TAOTOKEN_BASE_URL"], )配置项本身只有两个字段:Base URL 和 Key。真正影响体感的是模型名——embedding 模型和 chat 模型必须分别填写,且模型名要以控制台模型列表里的实际标识为准。把 embedding 模型名填进 chat 位置,通常会得到 404 或参数错误;反过来则会因为向量维度对不上导致检索阶段直接报错。
3. embedding 请求样本:切片、批量与 Token 口径
embedding 阶段的成本结构最简单:只有输入 Token,没有输出 Token。也就是说,文档有多少字被送进模型,就有多少 Token 被计入,与「切了多少块」无关,只与「送进去的文本总量」有关——切片策略影响的是检索质量,而不是 embedding 的账单,除非你因为策略不当而反复重建索引。
下面是一个批量向量化的最小可运行样本,同时把usage.prompt_tokens打成日志,方便后面做统计:
import hashlib import json from openai import OpenAI client = OpenAI( api_key="YOUR_API_KEY", base_url="https://taotoken.net/api", ) def content_hash(text: str) -> str: return hashlib.sha1(text.encode("utf-8")).hexdigest() def embed_chunks(chunks: list[str], model: str = "text-embedding-3-large", batch_size: int = 32) -> list[list[float]]: vectors, seen = [], set() for i in range(0, len(chunks), batch_size): batch = [c for c in chunks[i:i + batch_size] if c.strip()] # 同一次任务内按内容去重,减少重复计费 batch = [c for c in batch if not (content_hash(c) in seen) or seen.add(content_hash(c))] resp = client.embeddings.create(model=model, input=batch) print(json.dumps({ "stage": "embedding", "model": model, "batch": len(batch), "prompt_tokens": resp.usage.prompt_tokens, "total_tokens": resp.usage.total_tokens, }, ensure_ascii=False)) vectors.extend([d.embedding for d in resp.data]) return vectors几个容易踩的点:
- 批量大小控制在 16~64 条之间。批量越大吞吐越高,但单请求体过大容易触发长度上限,失败重试时反而重复计费。
- 按内容哈希去重。同一份文档被重复上传、同一切片在多个文件里重复出现时,去重能直接省掉一部分 embedding Token。
- 增量索引。只对新增或修改过的切片调用 embedding,未变更的切片直接复用已有向量,这是 embedding 阶段最有效的一刀。
- 维度一旦确定就不要换模型。换 embedding 模型意味着整个库的向量都要重算,同时向量维度变化会让原来的向量库直接失效。
4. chat 请求样本:检索结果进 Prompt 之后,Token 是怎么膨胀的
chat 阶段的账单结构和 embedding 完全不同:输入是「系统提示 + 检索到的上下文 + 用户问题」,输出是模型生成的内容,两者都计费,而且输出往往比输入贵。对知识库问答来说,输入侧最容易失控的地方是上下文注入量。
from openai import OpenAI client = OpenAI( api_key="YOUR_API_KEY", base_url="https://taotoken.net/api", ) SYSTEM_PROMPT = "你是知识库问答助手。只依据给定资料回答;资料中没有的内容,直接说明未检索到,不要编造。" def answer(question: str, contexts: list[str], top_k: int = 5): ctx = contexts[:top_k] user_content = ( "以下是检索到的资料片段:\n\n" + "\n\n---\n\n".join(ctx) + f"\n\n请依据上述资料回答问题:{question}" ) resp = client.chat.completions.create( model="gpt-4o-mini", # 以控制台模型列表为准 temperature=0.2, messages=[ {"role": "system", "content": SYSTEM_PROMPT}, {"role": "user", "content": user_content}, ], ) usage = resp.usage print({ "stage": "chat", "prompt_tokens": usage.prompt_tokens, "completion_tokens": usage.completion_tokens, "total_tokens": usage.total_tokens, }) return resp.choices[0].message.content这段代码里,决定成本的其实是三个参数,而不是模型本身:
top_k:检索回几段。从 5 提到 10,输入 Token 大致翻倍,但答案质量未必线性提升。- 单个切片长度:切片过长,会挤占上下文预算;切片过短,会引入更多条数。
- 输出上限:如果业务允许,给
max_tokens设一个合理上界,避免模型在开放式问题上长篇输出。
还有一个常被忽略的点:多轮对话会累积输入。用户连续追问时,如果把历史消息原样带下去,输入 Token 会随轮次增长。做法是把检索上下文每轮重新拼装,历史只保留最近一到两轮,或先做一次摘要压缩再入 Prompt。
5. 两阶段 Token 统计表:一张表看清钱花在哪
想把成本讲清楚,必须把两阶段分开计数。下面这张表可以直接照抄成你自己的台账模板,其中「示例量级」仅用于说明相对关系,实际数值请以你自己跑出来的usage字段为准。
| 阶段 | 触发时机 | 计入 Token 的字段 | 示例量级(单次任务) | 主要优化动作 |
|---|---|---|---|---|
| embedding | 文档入库、重建索引 | 仅prompt_tokens | 1 万个切片 × 约 300 Token ≈ 300 万输入 Token | 内容去重、增量索引、避免全量重建 |
| chat | 每次用户提问 | prompt_tokens+completion_tokens | top_k=5,每段约 300 Token ≈ 1500 输入 + 数百输出 | 控制 top_k、压缩上下文、限制输出长度 |
把两阶段写进同一份日志,才能算出「每回答一个问题,平均花掉多少 Token」:
import csv from datetime import datetime FIELDS = ["ts", "stage", "model", "prompt_tokens", "completion_tokens", "total_tokens", "note"] def log_usage(stage: str, model: str, prompt: int, completion: int, note: str = ""): with open("token_ledger.csv", "a", newline="", encoding="utf-8") as f: writer = csv.DictWriter(f, fieldnames=FIELDS) if f.tell() == 0: writer.writeheader() writer.writerow({ "ts": datetime.now().isoformat(timespec="seconds"), "stage": stage, "model": model, "prompt_tokens": prompt, "completion_tokens": completion, "total_tokens": prompt + completion, "note": note, })有了这份 CSV,就能回答几个关键问题:一次全量重跑索引消耗了多少?单个问题平均消耗多少?哪一天出现异常尖峰,是有人上传了大文件还是有人连续追问?把「阶段」列做一次分组求和,embedding 和 chat 的占比通常会让第一次看到的人有点意外——在文档更新不频繁的知识库里,chat 往往才是长期支出的主力;而在频繁重建索引的场景里,embedding 会阶段性反超。
6. 同一套 Key 接到 Claude Code、Codex 与 CC Switch
知识库问答服务跑通之后,很多人的下一步是用 CLI 工具在终端里直接查文档、改代码。这里可以复用同一套 Key,但要注意各工具的配置字段完全不同,不要把 Claude Code 的ANTHROPIC_*变量写给 Codex。
Claude Code:settings.json
在~/.claude/settings.json(或项目内的.claude/settings.json)里写:
{ "env": { "ANTHROPIC_BASE_URL": "https://taotoken.net/api", "ANTHROPIC_AUTH_TOKEN": "YOUR_API_KEY", "ANTHROPIC_MODEL": "claude-sonnet-4-5" } }保存后重开终端让环境变量生效。模型名以控制台模型列表为准,字段名保持ANTHROPIC_BASE_URL与ANTHROPIC_AUTH_TOKEN不要改动。更细的配置说明可以看 https://taotoken.net/doc/ClaudeCodeAnthropic?utm_source=taotoken_aicg_blog_end&utm_content=kb_qa_cc_doc 。
Codex:config.toml
Codex 走的是另一套字段,配置文件一般在~/.codex/config.toml:
model = "gpt-5-codex" model_provider = "taotoken" [model_providers.taotoken] name = "TaoToken" base_url = "https://taotoken.net/api" env_key = "TAOTOKEN_API_KEY" wire_api = "chat"然后在 shell 里导出 Key:
export TAOTOKEN_API_KEY=YOUR_API_KEY注意env_key指的是「从哪个环境变量读 Key」,不是 Key 本身;把ANTHROPIC_*那套变量名搬过来,只会得到鉴权失败。
CC Switch:三件套
用 CC Switch 这类切换器管理多套供应商时,需要填的其实只有三件套:
- Base URL:
https://taotoken.net/api - API Key:
YOUR_API_KEY - 模型名:按控制台模型列表选择,embedding 与 chat 分别对应不同的模型标识
三件套填完保存、激活对应配置,然后重启 CLI 工具,避免旧的环境变量残留在当前会话里。
7. 排障:embedding 与 chat 的报错不是一个思路
两阶段共用一个 Base URL,但报错语义差别很大,按下面这张对照表排查会比盲改配置快很多。
| 现象 | 更可能出问题的阶段 | 排查方向 |
|---|---|---|
| 401 / 鉴权失败 | 两阶段都可能 | Key 前后有空格或引号、环境变量未生效、.env未被加载 |
| 404 / 路径不存在 | 两阶段都可能 | Base URL 是否被客户端自动补了路径后缀,以控制台文档给的完整地址为准 |
| 索引写入报维度不一致 | embedding | 中途更换过 embedding 模型,旧向量与新向量维度不同,需要重建索引 |
| 检索为空、问答答非所问 | embedding + 检索 | 切片太碎或太长、相似度阈值过严、top_k 太小 |
| 上下文超限 | chat | 检索片段总量超过模型上下文,先降 top_k 再考虑压缩 |
| 429 / 请求过频 | 两阶段都可能 | 批量向量化并发过高,加退避重试,把批量调小一点 |
一个实用的定位手法:先用一条固定文本单独调 embedding,确认usage.prompt_tokens能正常返回;再用一条固定问题单独调 chat,确认输出正常。两段分别通了,再回到知识库服务里排查拼接逻辑,问题范围会小很多。
8. 成本观察清单与下一步
把上面的内容收敛成一份可以贴在项目 README 里的清单:
- Base URL 只有一个:
https://taotoken.net/api,Key 从控制台创建,代码里写YOUR_API_KEY; - embedding 只计输入 Token,优化重点是去重与增量,不是切片数量;
- chat 输入输出都计费,优化重点是top_k、上下文长度与输出上限;
- 两阶段分别打日志,落成 CSV,按阶段分组求和,才知道钱去了哪;
- CLI 工具配置各按各的字段写:Claude Code 用
ANTHROPIC_*,Codex 用config.toml,CC Switch 填三件套。
下一步可以按这个顺序动手:先在 https://taotoken.net/models/detail/chat?utm_source=taotoken_aicg_blog_end&utm_content=kb_qa_chat 里把 chat 模型跑一条请求,确认返回结构;再把知识库服务的两个客户端指向同一个 Base URL,用第 3、4 节的样本各发一次请求,对比usage字段;随后到 https://taotoken.net/coding-plan?utm_source=taotoken_aicg_blog_end&utm_content=kb_qa_plan 了解适合长期跑批的套餐形态;接着在 https://taotoken.net/console/api-keys?utm_source=taotoken_aicg_blog_end&utm_content=kb_qa_key 创建正式 Key,替换掉占位符;最后参考 https://taotoken.net/doc/ClaudeCodeAnthropic?utm_source=taotoken_aicg_blog_end&utm_content=kb_qa_cc 把 Claude Code 也接上,让「查文档」和「问知识库」共用同一套入口。整条链路跑通后,你会得到两样东西:一份能复现的两阶段请求样本,和一张能解释账单来源的 Token 统计表——对做知识库问答的人来说,后者往往比前者更值钱。