1. llm_pipeline.py 的校验-重试,为什么先把 Token 烧光
llm_pipeline.py校验-重试想换 Base URL,第一件能落地的事是去 TaoToken 建一把 Key:https://taotoken.net/?utm_source=taotoken_aicg_blog_end&utm_content= 。不过先别急着改代码,把「换通道」和「重试逻辑」两件事分开看,才知道这次改动的边界在哪。原文里LLMPipeline用AsyncOpenAI客户端跑summarize和extract_keywords两步,每步都要过JSONSchemaValidator;校验不过就走_execute_step里那个while retry_count <= max_retries,把上一轮的报错拼成修正提示重新发一次,并且按指数退避拉长间隔。这个设计的初衷没问题,问题是它把「模型输出不稳定」的成本全部转嫁到了 Token 上。
1.1 一次 summarize 失败,账单上是三次完整调用
_execute_step的循环不是只发一个「请修正」的短消息。为了让模型知道错在哪,绝大多数实现会把原始长文本、上一轮的完整输出、以及 validator 抛出的错误信息一起塞回 context。这意味着重试一次,输入侧的长度基本翻倍;max_retries=2时,同一段素材可能被完整送进去三遍。如果这一步还跑在按量计费的官方通道上,重试次数越多,越接近「花三倍钱拿一份兜底结果」的尴尬局面。
更隐蔽的是失败本身不会被立刻叫停。retry_count到顶之后,代码通常回落到fallback_output,返回一个结构合法但内容空壳的对象,流程继续往下走。看起来 pipeline 没崩,但token_usage已经记了一大笔,而这笔钱对应的产出质量,和直接跳过这一步差不多。所以在做接入改造前,先把「重试次数」「单次输入长度」「兜底触发频率」三个数字记下来,改造完才有对照。
1.2 JSONSchemaValidator 只认结构,不认你走哪条通道
很多人把「校验老失败」和「通道不给力」混在一起。其实JSONSchemaValidator是纯本地的活儿:它拿到的是模型返回的字符串,json.loads之后对着 schema 检查字段名、类型、必填项、长度约束。Base URL 换成什么,它都不关心,也不会因为你换了接入点就网开一面。
这个边界必须说清楚,因为它是判断「行不行」的关键。TaoToken 在这条链路里只做两件事:给你一把 API Key,给你一个统一的兼容入口https://taotoken.net/api。它不替JSONSchemaValidator做校验,不替while循环做重试,也不替fallback_output做兜底。你的重试策略、退避曲线、降级逻辑,全都还留在自己的代码里。换句话说,换通道解决的是「能不能稳定调到模型」和「账单口径是否统一」,不解决「模型输出为什么不符合 schema」——后者要从 prompt 约束和 schema 宽严上找原因。
1.3 真正值得改的只有客户端构造那一处
把上面两层拆开之后,改造范围就很小了:LLMPipeline内部逻辑一行不动,只把AsyncOpenAI(...)的构造参数换掉。原来那里可能是空参数走环境变量,也可能是硬编码了某个官方地址;现在改成显式传入base_url="https://taotoken.net/api"和一把从官网创建的 Key,其余全部保留。
这样做还有一个副作用是好的:因为只动了一处,回滚成本极低。真遇到问题,把构造函数改回去就能对比出到底是通道问题还是 schema 问题。下面几节就按这个思路,把 Key 的创建、客户端的改法、StepResult的验证逐个落地。
2. 把 AsyncOpenAI() 那一步从直连挪出来
2.1 先在模型广场确认模型 ID 再动手
打开 TaoToken 官网 注册登录,进控制台创建一把 API Key,把它记成YOUR_API_KEY占位(真实值写进环境变量,不要提交进仓库)。接着去模型广场看一眼当前可用的模型列表,把要做summarize和extract_keywords的那个模型 ID 抄下来——本文所有示例里的模型 ID 都写成占位符,实际取值以 模型广场 当时的列表为准,不要照着网上随便一个带日期后缀的名字填。
# 本地开发环境先设好,别写进代码 export TAOTOKEN_API_KEY=YOUR_API_KEY提示:Key 只在创建时完整显示一次,复制之后立刻存进密码管理器或本地
.env,后面settings类文件里一律引用变量名,不引用明文。
2.2 环境变量里 Base URL 和 Key 分开存
如果项目本来就用.env管理配置,加两行就够了。这里要注意一个高频错误:填进工具的地址是https://taotoken.net/api,末尾不要加/v1。AsyncOpenAI的 SDK 自己会拼/chat/completions,多写一层路径进不去。
# .env TAOTOKEN_API_KEY=YOUR_API_KEY TAOTOKEN_BASE_URL=https://taotoken.net/api TAOTOKEN_MODEL=YOUR_MODEL_ID官网页面地址和接口地址是两回事:注册、建 Key、看用量、查模型走https://taotoken.net/?utm_source=taotoken_aicg_blog_end&utm_content=;填进代码和环境变量的 Base URL 用https://taotoken.net/api。把这两个混在一起,最常见的结果就是请求打到落地页上,然后收到一段 HTML。
2.3 为什么不用 SDK 自带的重试
AsyncOpenAI构造函数里有max_retries参数,默认会自己吞掉一部分 5xx 和超时。放到这个 pipeline 里很危险:SDK 悄悄重试一轮,_execute_step又重试一轮,两套策略叠加,retry_count根本对不上实际请求次数,指数退避也白写了。所以构造函数里显式设max_retries=0,把重试权完整收回给业务层,账单和日志才对得上。
3. LLMPipeline 的客户端与 schema 怎么落盘
3.1 客户端构造:只改两行
假设原来的写法是client = AsyncOpenAI(),依赖环境变量里的官方配置。改完长这样:
# llm_pipeline/client.py import os from openai import AsyncOpenAI client = AsyncOpenAI( api_key=os.environ["TAOTOKEN_API_KEY"], # 从 https://taotoken.net 控制台创建 base_url=os.environ.get("TAOTOKEN_BASE_URL", "https://taotoken.net/api"), timeout=90.0, max_retries=0, # 重试统一交给 _execute_step,避免和 SDK 策略叠加 ) MODEL_ID = os.environ["TAOTOKEN_MODEL"] # 以模型广场当时的列表为准timeout给到 90 秒是有原因的:summarize这类任务输入长、输出也长,退避之后第二次请求又紧跟着发,60 秒经常不够。如果你的素材普遍超过几千字,可以把这一步的输入先做一次截断或分块,重试时才不会越滚越贵。
3.2 两个 schema:宽严要分开调
summarize和extract_keywords的输出结构不同,校验失败的常见原因也不同。前者容易在字段缺失上翻车,后者容易在数组长度和重复词上翻车。分开写 schema,重试时的修正提示才有针对性。
# llm_pipeline/schemas.py SUMMARIZE_SCHEMA = { "type": "object", "required": ["summary", "key_points"], "properties": { "summary": {"type": "string", "minLength": 30}, "key_points": { "type": "array", "items": {"type": "string", "minLength": 4}, "minItems": 2, "maxItems": 8, }, }, "additionalProperties": False, } KEYWORDS_SCHEMA = { "type": "object", "required": ["keywords"], "properties": { "keywords": { "type": "array", "items": {"type": "string", "minLength": 2}, "minItems": 3, "maxItems": 15, "uniqueItems": True, } }, "additionalProperties": False, }additionalProperties: False是个双刃剑。它能让输出更干净,但也会让模型多写一个无关字段就直接判失败,多消耗一次重试。第一次接入时建议先放开这一条,等输出稳定了再收紧。
3.3 _execute_step:退避和兜底保持原样
改造的重点是「通道换、策略不换」。循环结构、修正提示的拼法、指数退避的基数,全部沿用原来的实现,只在请求参数上补一个response_format,让模型尽量吐 JSON。
# llm_pipeline/pipeline.py import asyncio, json from dataclasses import dataclass, field from jsonschema import Draft202012Validator, ValidationError from .client import client, MODEL_ID from .schemas import SUMMARIZE_SCHEMA, KEYWORDS_SCHEMA @dataclass class StepResult: ok: bool data: dict retry_count: int = 0 token_usage: dict = field(default_factory=dict) class LLMPipeline: def __init__(self, model: str = MODEL_ID, max_retries: int = 2): self.model = model self.max_retries = max_retries def _build_messages(self, task: str, text: str, last_error: str | None): sys = f"你是文本处理助手,只输出 JSON,任务:{task}。" user = text if not last_error else ( f"{text}\n\n上一次输出未通过校验,错误:{last_error}\n" "请只修正结构问题,不要新增解释文字。" ) return [{"role": "system", "content": sys}, {"role": "user", "content": user}] async def _execute_step(self, task, text, schema, fallback): retry_count = 0 total_tokens = 0 last_error = None while retry_count <= self.max_retries: resp = await client.chat.completions.create( model=self.model, messages=self._build_messages(task, text, last_error), response_format={"type": "json_object"}, temperature=0.2 if retry_count == 0 else 0.0, ) total_tokens += resp.usage.total_tokens try: payload = json.loads(resp.choices[0].message.content) Draft202012Validator(schema).validate(payload) return StepResult(True, payload, retry_count, {"total_tokens": total_tokens}) except (json.JSONDecodeError, ValidationError) as e: last_error = str(e)[:300] retry_count += 1 if retry_count <= self.max_retries: await asyncio.sleep(0.5 * (2 ** (retry_count - 1))) return StepResult(False, fallback, retry_count, {"total_tokens": total_tokens}) async def summarize(self, text: str): return await self._execute_step( "输出 summary 与 key_points", text, SUMMARIZE_SCHEMA, {"summary": "", "key_points": []}) async def extract_keywords(self, text: str): return await self._execute_step( "输出 keywords 数组", text, KEYWORDS_SCHEMA, {"keywords": []})注意total_tokens是累加的,不是最后一次的。这个细节决定后面能不能看懂账单:如果只记最后一轮,重试造成的额外消耗就完全看不见了。fallback依然由业务层自己给,没有人替你决定兜底内容长什么样。
4. 跑 main(),用 StepResult 对账 token_usage 和 retry_count
4.1 第一次跑,先确认两步都有结果
# main.py import asyncio from llm_pipeline.pipeline import LLMPipeline TEXT = "把这里换成你自己的测试素材,长度尽量接近真实场景。" async def main(): pipe = LLMPipeline() r1 = await pipe.summarize(TEXT) r2 = await pipe.extract_keywords(TEXT) for name, r in (("summarize", r1), ("extract_keywords", r2)): print(f"{name}: ok={r.ok} retry={r.retry_count} " f"tokens={r.token_usage.get('total_tokens')}") asyncio.run(main())第一次运行的目标不是拿到漂亮结果,而是确认两个StepResult都能回来。如果summarize的ok=False,先看retry_count是否等于max_retries——等于说明每一轮都被 schema 挡下来了,问题多半在 prompt 或 schema;小于则说明请求本身就没成功,去看异常信息。
4.2 用 token_usage 判断这次重试值不值
retry_count=0且ok=True是最理想的:一次请求、一次通过,total_tokens就是这次输入加输出的真实消耗。retry_count=1时,如果total_tokens是零重试情况的两倍左右,说明修正提示把上下文撑长了,这条路径就有优化空间——比如把原文本换成摘要预览再重试,而不是整段重发。
还有一种情况要警惕:ok=False且total_tokens很高。这说明兜底结果是自己花钱买来的,pipeline 表面没报错,实际白跑。把这两个字段接进日志,跑几天就能看出哪一类素材最容易触发重试。
4.3 顺手去控制台核对一次用量
服务端记的用量和本地total_tokens对不上时,先排除是不是 SDK 层重试被悄悄打开了。想看清楚每把 Key 的调用情况,登录 https://taotoken.net/?utm_source=taotoken_aicg_blog_end&utm_content= 进控制台看用量明细,把这段时间的请求数和自己日志里的次数比一比,差额通常就是隐藏重试。
5. 换到兼容通道后常见的几类报错
5.1 401:Key 没读到,或者复制缺了尾巴
AuthenticationError在这个场景里八成不是 Key 无效,而是没读进来。检查三件事:环境变量名和代码里os.environ[...]是否一致;.env是否真的被加载(很多项目要显式load_dotenv());从控制台复制时有没有漏掉末尾字符。改完 Key 记得重启进程,长驻服务不会自动读新环境变量。
5.2 校验依然反复失败:这不是通道的问题
如果retry_count每次都跑满,last_error又集中在同一类字段上,那就是 prompt 和 schema 的匹配问题。三条实用做法:把additionalProperties先放开;在 system prompt 里直接给一个字段完整的 JSON 例子;把minItems、maxItems这类硬边界放宽一档。改完再跑一次main(),看retry_count有没有下降。
5.3 404 与路径:base_url 别多写一层
NotFoundError常见于把 Base URL 写成https://taotoken.net/api/v1,SDK 再拼一次/chat/completions就找不到路由。正确值就是https://taotoken.net/api。另一种 404 是模型 ID 抄错了——去模型广场核对一遍,别用记忆里的名字。
| 现象 | 优先检查 | 处理 |
|---|---|---|
| 401 | 环境变量是否加载 | 重启进程后重试 |
| 404 | base_url是否带/v1 | 改回https://taotoken.net/api |
| 校验跑满重试 | schema 约束是否过严 | 放宽边界并补 JSON 示例 |
| 用量对不上 | SDK 层是否自动重试 | 构造函数设max_retries=0 |
6. 两步跑顺之后,把 pipeline 接到真实任务上
summarize与extract_keywords都能一次通过,说明客户端、Key、模型 ID 这三样配对了。接下来可以做的事有几件:先拿几条真实素材跑批量,观察retry_count的分布;把StepResult落进表里,方便按素材类型统计兜底率;再考虑要不要给extract_keywords单独换一个更省的小模型。
想先确认模型 ID 和 Key 没配错,去 TaoToken 模型对话 用同一把 Key 发一条测试消息最直接。如果这个 pipeline 后面还要长期跑批,可以看一眼 Coding Plan 的套餐是否覆盖得住;需要再开一把 Key 分环境用,就在 控制台 API Keys 里创建。要是打算把同样的通道挪到 Claude Code 里做代码侧的辅助,环境变量对照表在 Claude Code 接入文档。
最后提醒一句:fallback_output触发的时候,别让它静默过去。那代表这一段素材的处理质量已经不达标了,把retry_count和total_tokens一起打出来,比事后翻账单要省事得多。