1. 为什么你的 LLM 项目总在月底被账单“背刺”
做智能体开发的朋友大概率都经历过这种时刻:功能跑通了,Demo 演示也顺利,结果月底一看 API 账单,整个人都不好了。尤其是用 LangChain 搭多步 Chain 或者 Agent 的时候,一次用户请求背后可能触发五六次 LLM 调用,每次调用都在烧 Token,而你根本不知道钱具体花在了哪一步。
这个问题的根源在于:LLM 调用是黑盒的。你调用chain.invoke(),它内部可能先做意图识别、再检索、再总结、再格式化输出,每一步都是一次独立的模型请求。如果没有埋点,你只能看到最终结果,看不到中间过程。等到发现成本失控,钱已经花出去了。
LangChain 的回调机制(Callback System)就是为解决这个问题设计的。它允许你在 Chain、LLM、Tool、Retriever 的各个生命周期节点插入钩子函数,实时捕获 Token 使用量、调用耗时、模型名称等关键数据。基于这些数据,你可以做三件事:实时监控(知道钱花在哪)、阈值告警(快超支时收到通知)、自动降级(超预算时切换到便宜模型或直接拦截)。
这篇文章面向的是已经用 LangChain 搭过 Chain 或 Agent、但对成本控制还没有系统方案的开发者。我会从回调埋点开始,一步步给出可复制的配置代码,演示如何设置 Token 预算阈值、如何在超预算时触发降级、以及如何验证拦截是否生效。全程用 OpenAI 兼容接口举例,你可以直接替换成自己的模型服务地址。
先说结论:成本控制的核心不是“省”,而是“可控”。你需要知道每一分钱花在哪、什么时候会超、超了之后怎么办。下面进入具体操作。
2. 用 TaoToken 统一接入多模型,让回调数据有处可查
在讲回调配置之前,先解决一个前置问题:多模型接入的统一性。很多团队同时用 GPT-4、GPT-3.5、Claude 等不同模型,每个模型的 Token 计费方式、返回字段格式都不一样。如果回调里要针对每个模型写不同的解析逻辑,维护成本很高。
我试过用 TaoToken 做统一接入层,它提供 OpenAI 兼容的 API 格式,所有模型走同一个 Base URL,返回结构一致。这样回调里的 Token 解析逻辑只需要写一套,不用为每个模型单独适配。
具体来说,TaoToken 的核心价值在于:
统一 Base URL:所有模型请求都发到https://taotoken.net/api,不用在代码里维护多个 endpoint。回调里拿到的LLMResult结构统一,token_usage字段格式一致。
模型 ID 标准化:通过统一的 Model ID 指定模型,回调里serialized参数拿到的模型名称是标准化的,方便做成本映射。
Key 管理集中:一个 API Key 管理所有模型调用,回调里做用户级配额时不用关心 Key 的归属问题。
如果你还没配置过,可以按下面三步操作:
第一步,访问 TaoToken 控制台 创建一个 API Key。建议为不同环境(开发/测试/生产)创建不同的 Key,方便后续做项目级配额。
第二步,在 API Keys 页面 复制你的 Key,保存到环境变量里。不要硬编码在代码中。
第三步,在 LangChain 里配置 ChatOpenAI 时,把base_url指向 TaoToken 的 API 地址:
import os from langchain_openai import ChatOpenAI llm = ChatOpenAI( model="gpt-3.5-turbo", # 或 gpt-4、claude-3-sonnet 等 base_url="https://taotoken.net/api", api_key=os.environ["TAOTOKEN_API_KEY"], temperature=0.3, )配置好之后,你的所有 LLM 调用都会经过 TaoToken 转发,回调里拿到的 Token 数据是统一的。这样后面写成本监控和预算拦截时,只需要处理一种数据格式。
如果你用的是 Claude Code 做开发辅助,TaoToken 也支持 Anthropic 格式的接入,具体可以参考 Claude Code 接入文档。不过本文主要聚焦 LangChain 回调,Claude Code 的配置不展开。
有一点需要注意:TaoToken 是 API 接入层,不是模型本身。它帮你统一了接口格式和 Key 管理,但 Token 计费还是按实际调用的模型来算。所以回调里的成本计算逻辑,还是要根据你实际使用的模型来配置单价。
3. 可复制的回调配置:Token 预算拦截器完整代码
这一节给出完整的可复制配置。核心思路是:写一个自定义的AsyncCallbackHandler,在on_llm_end里捕获 Token 使用量,累计到当前请求的预算上下文中;当累计值超过阈值时,在on_llm_start里抛出异常,阻止后续 LLM 调用。
先看配置文件。我习惯把预算策略放在一个独立的 JSON 文件里,方便不同环境切换:
{ "budget_policy": { "per_request_limit": 8000, "per_request_soft_limit": 6000, "daily_limit": 200000, "hourly_limit": 30000, "alert_threshold": 0.8, "degradation_model": "gpt-3.5-turbo", "premium_model": "gpt-4-turbo", "cost_per_1k_tokens": { "gpt-4-turbo": 0.02, "gpt-3.5-turbo": 0.001 } } }把这个文件保存为budget_config.json,放在项目根目录。下面是对应的回调实现:
import json import time from dataclasses import dataclass, field from typing import Any, Dict, List, Optional from langchain_core.callbacks import AsyncCallbackHandler from langchain_core.outputs import LLMResult @dataclass class BudgetContext: """单次请求的预算上下文""" request_id: str total_tokens: int = 0 total_cost: float = 0.0 call_count: int = 0 soft_limit_hit: bool = False hard_limit_hit: bool = False model_usage: Dict[str, int] = field(default_factory=dict) class TokenBudgetCallback(AsyncCallbackHandler): """Token 预算拦截回调""" def __init__(self, config_path: str = "budget_config.json"): with open(config_path, "r") as f: cfg = json.load(f)["budget_policy"] self.per_request_limit = cfg["per_request_limit"] self.soft_limit = cfg["per_request_soft_limit"] self.alert_threshold = cfg["alert_threshold"] self.cost_map = cfg["cost_per_1k_tokens"] self.degradation_model = cfg["degradation_model"] self.context = BudgetContext(request_id=f"req-{int(time.time())}") async def on_llm_start( self, serialized: Dict[str, Any], prompts: List[str], **kwargs: Any, ) -> None: """LLM 调用前检查预算""" if self.context.hard_limit_hit: raise BudgetExceededError( f"请求 {self.context.request_id} 已超硬限制 " f"({self.context.total_tokens}/{self.per_request_limit}),拒绝继续调用" ) # 预估本次 Prompt 的 Token(粗略估算:1 token ≈ 4 字符) estimated = sum(len(p) // 4 for p in prompts) if self.context.total_tokens + estimated > self.per_request_limit: self.context.hard_limit_hit = True raise BudgetExceededError( f"预估 Token {estimated} 将导致超限,当前已用 " f"{self.context.total_tokens},限制 {self.per_request_limit}" ) async def on_llm_end(self, response: LLMResult, **kwargs: Any) -> None: """LLM 调用后累计 Token 和成本""" for gen_list in response.generations: for gen in gen_list: info = gen.generation_info or {} usage = info.get("token_usage", {}) tokens = usage.get("total_tokens", 0) model = (response.llm_output or {}).get("model_name", "unknown") self.context.total_tokens += tokens self.context.call_count += 1 self.context.model_usage[model] = ( self.context.model_usage.get(model, 0) + tokens ) # 计算成本 price = self.cost_map.get(model, 0.001) self.context.total_cost += (tokens / 1000) * price # 软限制检查 if ( not self.context.soft_limit_hit and self.context.total_tokens >= self.soft_limit ): self.context.soft_limit_hit = True print( f"[BUDGET WARNING] 请求 {self.context.request_id} " f"已达软限制 {self.soft_limit},当前 {self.context.total_tokens} tokens" ) def get_summary(self) -> Dict[str, Any]: return { "request_id": self.context.request_id, "total_tokens": self.context.total_tokens, "total_cost": round(self.context.total_cost, 6), "call_count": self.context.call_count, "model_usage": self.context.model_usage, "soft_limit_hit": self.context.soft_limit_hit, "hard_limit_hit": self.context.hard_limit_hit, } class BudgetExceededError(Exception): """预算超限异常""" pass这段代码的关键设计点:
预检在on_llm_start:在 LLM 调用真正发生之前,先估算 Prompt 的 Token 数,如果加上已用量会超限,直接抛异常。这样能避免“钱已经花了才发现超支”的问题。
累计在on_llm_end:每次 LLM 调用结束后,从generation_info里提取真实的token_usage,累加到上下文。不同模型的返回字段可能略有差异,这里做了兼容处理。
软硬双阈值:软限制(6000)触发告警但不拦截,硬限制(8000)直接抛异常。这样给开发者一个缓冲区间,可以在软限制触发时做降级处理。
成本实时计算:每次调用后立刻算出累计成本,方便后续做成本维度的配额管理。
把这个回调注入到 Chain 里:
from langchain_core.prompts import ChatPromptTemplate callback = TokenBudgetCallback("budget_config.json") llm = ChatOpenAI( model="gpt-3.5-turbo", base_url="https://taotoken.net/api", api_key=os.environ["TAOTOKEN_API_KEY"], callbacks=[callback], ) prompt = ChatPromptTemplate.from_template("请详细分析以下问题:{question}") chain = prompt | llm try: result = chain.invoke( {"question": "解释一下 Transformer 的注意力机制"}, config={"callbacks": [callback]}, ) print(callback.get_summary()) except BudgetExceededError as e: print(f"预算拦截:{e}")注意config={"callbacks": [callback]}这一行很关键。LangChain 的 Chain 和 LLM 各自有独立的回调配置,如果只在 LLM 上设置回调,Chain 级别的生命周期事件(如on_chain_start)不会触发。两边都设置才能完整覆盖。
4. 验证请求:一次超预算拦截的完整演示
配置写好了,怎么验证它真的生效?这一节用一个具体的超预算场景来演示。
先构造一个会触发硬限制的请求。假设per_request_limit设为 8000,我们故意传一个超长文本:
import asyncio from langchain_openai import ChatOpenAI from langchain_core.prompts import ChatPromptTemplate async def test_budget_interception(): callback = TokenBudgetCallback("budget_config.json") llm = ChatOpenAI( model="gpt-3.5-turbo", base_url="https://taotoken.net/api", api_key=os.environ["TAOTOKEN_API_KEY"], callbacks=[callback], ) prompt = ChatPromptTemplate.from_template( "请逐段分析以下文本,每段给出详细评论:\n\n{text}" ) chain = prompt | llm # 构造一个超长文本,约 40000 字符 ≈ 10000 tokens long_text = "这是一段测试文本,用于验证 Token 预算拦截机制。" * 1000 try: result = await chain.ainvoke( {"text": long_text}, config={"callbacks": [callback]}, ) print("调用成功(未触发拦截)") print(callback.get_summary()) except BudgetExceededError as e: print(f"拦截成功:{e}") print(f"拦截时状态:{callback.get_summary()}") asyncio.run(test_budget_interception())运行这段代码,你会看到类似这样的输出:
拦截成功:预估 Token 10000 将导致超限,当前已用 0,限制 8000 拦截时状态:{'request_id': 'req-1712345678', 'total_tokens': 0, 'total_cost': 0.0, 'call_count': 0, 'model_usage': {}, 'soft_limit_hit': False, 'hard_limit_hit': True}注意total_tokens是 0,因为拦截发生在on_llm_start阶段,LLM 调用还没有真正执行。这就是预检的价值:在花钱之前拦住。
再测试一个软限制场景。把文本长度控制在 6000-8000 tokens 之间:
async def test_soft_limit(): callback = TokenBudgetCallback("budget_config.json") llm = ChatOpenAI( model="gpt-3.5-turbo", base_url="https://taotoken.net/api", api_key=os.environ["TAOTOKEN_API_KEY"], callbacks=[callback], ) prompt = ChatPromptTemplate.from_template("总结:{text}") chain = prompt | llm # 约 7000 tokens 的文本 medium_text = "这是一段中等长度的测试文本。" * 500 result = await chain.ainvoke( {"text": medium_text}, config={"callbacks": [callback]}, ) summary = callback.get_summary() print(f"调用成功,Token 使用:{summary['total_tokens']}") print(f"软限制触发:{summary['soft_limit_hit']}") print(f"成本:${summary['total_cost']}") asyncio.run(test_soft_limit())输出会显示soft_limit_hit: True,同时打印告警信息。这时候你可以选择在软限制触发后做降级处理,比如把后续调用切换到gpt-3.5-turbo。
如果你想在真实的多步 Chain 里验证,可以搭一个简单的 Agent:
from langchain.agents import AgentExecutor, create_openai_tools_agent from langchain_core.tools import tool @tool def search(query: str) -> str: """搜索工具,返回模拟结果""" return f"关于 {query} 的搜索结果:..." tools = [search] agent_prompt = ChatPromptTemplate.from_template( "你是一个助手,可以使用工具回答问题。\n\n问题:{input}" ) agent = create_openai_tools_agent(llm, tools, agent_prompt) executor = AgentExecutor(agent=agent, tools=tools, callbacks=[callback]) result = await executor.ainvoke( {"input": "帮我搜索 LangChain 回调机制"}, config={"callbacks": [callback]}, ) print(callback.get_summary())Agent 场景下,一次用户请求可能触发多次 LLM 调用(思考→调工具→再思考→输出)。回调会累计所有调用的 Token,最终汇总在get_summary()里。如果中间某次调用触发了硬限制,整个 Agent 执行会被中断,已消耗的 Token 会记录在上下文里。
验证通过后,你可以把get_summary()的数据上报到监控系统。最简单的做法是打印到日志,进阶做法是发送到 Prometheus 或自建的时序数据库。TaoToken 控制台也提供了用量统计,可以作为对账参考。
5. 常见报错排查:401、local proxy failed、reading choices 怎么处理
配置回调的过程中,最容易遇到的报错集中在接入层和回调数据解析层。这一节列出几个高频问题和对应对策。
报错一:401 Unauthorized
openai.AuthenticationError: Error code: 401 - {'error': {'message': 'Invalid API key'}}这个报错说明 API Key 配置有问题。检查三个地方:第一,环境变量TAOTOKEN_API_KEY是否设置正确,可以用echo $TAOTOKEN_API_KEY确认;第二,ChatOpenAI初始化时api_key参数是否传了正确的值;第三,Key 是否已过期或被禁用,去 API Keys 页面 确认状态。
如果用的是.env文件,注意load_dotenv()的调用时机要在ChatOpenAI初始化之前。
报错二:local proxy failed / Connection error
openai.APIConnectionError: Connection error.这个报错通常是网络层的问题。检查base_url是否写对,应该是https://taotoken.net/api,不要多加路径或斜杠。如果公司网络有出口限制,确认能正常访问该地址。另外检查是否有环境变量HTTP_PROXY或HTTPS_PROXY干扰了请求,可以临时 unset 掉再试。
报错三:reading 'choices' / KeyError: 'choices'
KeyError: 'choices'这个报错说明回调里解析LLMResult时,假设了返回结构里有choices字段,但实际返回格式不同。在on_llm_end里,不要直接访问response.llm_output["choices"],而是用.get()做兼容:
async def on_llm_end(self, response: LLMResult, **kwargs): llm_output = response.llm_output or {} model = llm_output.get("model_name", "unknown") # 不要写 llm_output["choices"],用 get for gen_list in response.generations: for gen in gen_list: usage = (gen.generation_info or {}).get("token_usage", {}) # 处理 usage报错四:OAuth / token refresh failed
如果你用的是需要 OAuth 的模型服务,可能会遇到 token 刷新失败。LangChain 的ChatOpenAI默认用 API Key 认证,不涉及 OAuth。如果你在用其他需要 OAuth 的集成,检查 token 是否过期、refresh token 是否有效。对于 TaoToken 接入,直接用 API Key 即可,不需要 OAuth 流程。
报错五:回调没有触发 / Token 数据为 0
回调配置了但get_summary()返回全 0,通常是两个原因:第一,回调没有同时注入到 Chain 和 LLM,只在一边设置了;第二,用的是同步invoke但回调是AsyncCallbackHandler,需要改用ainvoke或者用同步的BaseCallbackHandler。
检查方法:在on_llm_end里加一行print("callback triggered"),看是否真的被调用。如果没有打印,说明回调没注入成功。
报错六:BudgetExceededError 没有抛出
预检逻辑没生效,检查on_llm_start里的估算逻辑。如果 Prompt 很短但实际输出很长,预检可能不会触发,但on_llm_end里的累计会触发软限制。硬限制的预检只检查 Prompt 部分,输出部分的 Token 无法预知,所以硬限制主要防的是“输入超长”场景。对于输出超长,需要在on_llm_new_token里做流式拦截(如果开启了 streaming)。
排查完这些常见问题,你的回调应该能稳定运行了。如果还有异常,可以去 接入文档 查一下接口返回格式的说明,对照回调里的解析逻辑。
6. 从监控到降级:把预算控制接入你的日常开发流
回调跑通之后,下一步是把它变成日常开发的一部分。我的做法是:在开发环境用宽松阈值(只告警不拦截),在预发环境用中等阈值(软限制触发降级),在生产环境用严格阈值(硬限制直接拦截)。
降级策略可以这样实现:在on_llm_end里检测到软限制触发后,动态切换后续调用的模型。LangChain 支持在运行时通过config覆盖模型参数,但更简单的做法是在 Chain 层面做路由:
async def smart_invoke(chain, inputs, callback): try: return await chain.ainvoke( inputs, config={"callbacks": [callback]} ) except BudgetExceededError: # 降级到便宜模型重试 cheap_llm = ChatOpenAI( model="gpt-3.5-turbo", base_url="https://taotoken.net/api", api_key=os.environ["TAOTOKEN_API_KEY"], callbacks=[callback], ) cheap_chain = chain.prompt | cheap_llm return await cheap_chain.ainvoke( inputs, config={"callbacks": [callback]} )对于长期运行的 Agent 服务,建议把预算上下文持久化到 Redis,这样跨请求的累计消耗也能追踪。TaoToken 的 Coding Plan 提供了包月套餐,适合高频调用的场景,配合回调的用量统计可以更精确地做容量规划。
如果你还在选模型阶段,可以先用 模型对话 测试不同模型的输出质量和 Token 消耗,再决定生产环境用哪个。实测下来,简单任务用 gpt-3.5-turbo 能省 90% 以上的成本,复杂推理再切到 gpt-4-turbo。
最后提醒一点:回调里的成本计算用的是硬编码单价,实际账单可能有差异。建议每周对一次账,用 TaoToken 控制台的用量数据校准回调里的cost_map。这样你的预算拦截才会越来越准。