
1. 工具堆到 50 个模型开始“装傻”是怎么回事如果你正在用 LangChain 搭 Agent并且工具数量已经上到几十个大概率遇到过这种场面用户只是说了句“你好”模型却要先把 50 个工具的 JSON Schema 从头读一遍用户问“帮我算个平均数”模型在几十段工具描述里翻来翻去最后选了个八竿子打不着的工具或者干脆不调用工具直接编答案。这不是模型变笨了而是你一次性把太多无关信息塞进了它的上下文窗口。LangChain 1.1 的 Middleware 机制给了我们一个很干净的解法在每次模型调用之前拦截请求根据当前 Agent 的状态动态决定这次到底暴露哪些工具。这正是 Claude Skills 的核心思路——渐进式披露按需加载。本文要做的就是把原文那套wrap_model_callrequest.override(toolsfiltered_tools)的动态过滤逻辑完整复现出来同时把模型通道接到 TaoToken 上让你拿到 Key 之后能直接跑通整个 Agent。适合谁看已经写过基础 LangChain Agent、手里工具超过 10 个、想控制 token 消耗和提升工具选择准确率的开发者。你需要对 Python 和 LangChain 的tool装饰器有基本了解剩下的步骤我都会给全。整篇文章的结构是这样先讲清楚传统“全量工具暴露”的痛点再配置 TaoToken 的模型通道然后一步步写 SkillState、Loader 工具、SkillMiddleware最后用一组销售数据跑测试通过日志验证动态过滤确实生效。TaoToken 在这里只负责模型通道的 Key 和 Base URL不参与 Middleware 的过滤逻辑两者职责分开后面配置时我会特别标注。2. 把 LangChain Agent 的模型通道接到 TaoToken原文 3.2 那一步是直接配 DeepSeek 官方通道这里我们改成走 TaoToken。原因很简单你只需要一个 Key就能在 LangChain 的兼容 OpenAI 客户端里调用 DeepSeek-v3.2 这类模型不用为每个模型单独维护一套鉴权。TaoToken 在这里的角色就是模型通道提供方给你 Key 和 Base URLMiddleware 的过滤逻辑完全不受影响。先打开 https://taotoken.net/?utm_sourcetaotoken_aicg_blog_end 创建 Key。登录后在控制台里找到 API Keys 页面新建一个 Key 并复制保存。这个 Key 就是你后面填进 LangChain 模型客户端的凭证。拿到 Key 之后在项目根目录建一个.env文件把 Key 写进去# .env TAOTOKEN_API_KEYsk-你的TaoToken密钥注意 Base URL 的写法这是最容易踩坑的地方。TaoToken 的 API 地址是https://taotoken.net/api不带/v1也不要把官网那个带 UTM 参数的地址填进去。官网地址是给人看的Base URL 是给程序调用的两者不能混。如果你把?utm_source...那一长串填进 Base URL请求会直接 404 或者鉴权失败。在 LangChain 里如果你用的是兼容 OpenAI 的 ChatModel配置大概是这样import os from dotenv import load_dotenv from langchain_openai import ChatOpenAI load_dotenv(overrideTrue) model ChatOpenAI( modeldeepseek-v3.2, api_keyos.getenv(TAOTOKEN_API_KEY), base_urlhttps://taotoken.net/api, temperature0.7, )如果你沿用原文那个自定义的DeepSeekReasonerChatModel适配器改法也一样把api_key指向 TaoToken 的 Key把base_url指向https://taotoken.net/api模型名按你实际要用的填。适配器内部怎么处理推理字段是它自己的事通道层只认 Key 和 Base URL。这里再强调一次职责边界TaoToken 只出现在模型通道配置里。SkillMiddleware读的是request.state里的skills_loaded跟模型走哪个通道没有任何关系。你换成别的兼容通道Middleware 代码一行都不用动。3. 可复制配置SkillState、Loader 工具与 SkillMiddleware这一节是全文的技术主体把原文 3.5 到 3.9 的代码完整走一遍。我按依赖顺序拆开你可以直接照着敲。3.1 定义 SkillState 状态Agent 需要在多轮调用之间记住“哪些技能已经加载了”所以我们要在状态里加一个skills_loaded字段。用Annotated配一个累加 reducer保证新加载的技能是追加而不是覆盖from typing import Annotated, List from langgraph.graph import MessagesState def skill_list_accumulator(current: List[str], new: List[str]) - List[str]: if not current: return new combined current [s for s in new if s not in current] return combined class SkillState(MessagesState): skills_loaded: Annotated[List[str], skill_list_accumulator] []MessagesState负责消息历史skills_loaded负责技能清单两者互不干扰。3.2 定义 Loader 工具和功能工具工具分三类Loader 工具始终可见用来加载技能数据分析和文本处理工具只在对应技能加载后才可见。Loader 工具返回Command在更新消息的同时把技能名写进skills_loadedfrom langgraph.types import Command from langchain_core.messages import ToolMessage from langchain_core.tools import tool tool def skill_data_analysis(runtime) - Command: 加载数据分析技能。 instructions 数据分析技能已加载可用工具calculate_statistics、generate_chart return Command(update{ messages: [ToolMessage(contentinstructions, tool_call_idruntime.tool_call_id)], skills_loaded: [data_analysis] }) tool def skill_text_processing(runtime) - Command: 加载文本处理技能。 instructions 文本处理技能已加载可用工具summarize_text、extract_keywords return Command(update{ messages: [ToolMessage(contentinstructions, tool_call_idruntime.tool_call_id)], skills_loaded: [text_processing] })功能工具就是普通的tool这里给两个数据分析的示例tool def calculate_statistics(numbers: List[float]) - str: 计算一组数字的统计信息。 import statistics if not numbers: return 错误数字列表为空 return f统计结果: mean{statistics.mean(numbers)}, max{max(numbers)} tool def generate_chart(data: List[float], chart_type: str bar) - str: 根据数据生成图表模拟。 return f已生成 {chart_type} 图表包含 {len(data)} 个数据点把工具分组方便后面做映射LOADER_TOOLS [skill_data_analysis, skill_text_processing] DATA_ANALYSIS_TOOLS [calculate_statistics, generate_chart] ALL_TOOLS LOADER_TOOLS DATA_ANALYSIS_TOOLS3.3 工具映射与过滤函数过滤逻辑的核心是一张“技能到工具”的映射表加上一个根据skills_loaded拼装工具列表的函数SKILL_TOOL_MAPPING { data_analysis: DATA_ANALYSIS_TOOLS, } def get_tools_for_skills(skills_loaded: List[str]) - List: tools list(LOADER_TOOLS) for skill_name in skills_loaded: if skill_name in SKILL_TOOL_MAPPING: tools.extend(SKILL_TOOL_MAPPING[skill_name]) return tools注意 Loader 工具永远在列表里因为模型需要它们来触发技能加载。3.4 实现 SkillMiddleware这是整个方案的关键。wrap_model_call在每次模型调用前执行从request.state读skills_loaded算出过滤后的工具列表再用request.override(toolsfiltered_tools)替换掉原始请求里的工具from typing import Callable, List from langchain.agents.middleware import AgentMiddleware, ModelRequest, ModelResponse class SkillMiddleware(AgentMiddleware): def __init__(self, verbose: bool True): super().__init__() self.verbose verbose self.call_count 0 def _get_skills_from_state(self, request: ModelRequest) - List[str]: if hasattr(request, state) and request.state is not None: if isinstance(request.state, dict): return request.state.get(skills_loaded, []) return getattr(request.state, skills_loaded, []) return [] def wrap_model_call( self, request: ModelRequest, handler: Callable[[ModelRequest], ModelResponse], ) - ModelResponse: self.call_count 1 skills_loaded self._get_skills_from_state(request) filtered_tools get_tools_for_skills(skills_loaded) if self.verbose: print(f[SkillMiddleware] 第 {self.call_count} 次模型调用) print(fskills_loaded: {skills_loaded}) print(f过滤后工具: {[t.name for t in filtered_tools]}) filtered_request request.override(toolsfiltered_tools) return handler(filtered_request)request.override()返回的是一个新请求对象原始请求不变这样多个 Middleware 串联时不会互相污染。3.5 用 create_agent 组装最后把所有组件装进 Agentfrom langchain.agents import create_agent SYSTEM_PROMPT 你是一个智能助手。 1. 你有两类工具Skill Loader 和功能工具。 2. 当用户请求某个功能时如果当前没有对应功能工具先调用 Skill Loader 加载技能。 3. 加载后使用新获得的工具完成任务。 agent create_agent( modelmodel, toolsALL_TOOLS, middleware(SkillMiddleware(verboseTrue),), state_schemaSkillState, system_promptSYSTEM_PROMPT, )toolsALL_TOOLS是把所有工具注册进去但真正每次发给模型的是 Middleware 过滤后的子集。这就是“注册全量、暴露按需”的写法。4. 验证请求跑销售数据测试看日志确认过滤生效配置写完了得用实际请求验证。构造一个销售数据统计的输入初始skills_loaded为空from langchain_core.messages import HumanMessage test_input { messages: [HumanMessage(content我有一组销售数据 [150, 200, 180, 220, 190]请帮我计算统计信息)], skills_loaded: [] } result agent.invoke(test_input)跑起来之后重点看SkillMiddleware打印的日志。预期会看到两次模型调用第一次调用时skills_loaded是空列表过滤后只剩 2 个 Loader 工具[SkillMiddleware] 第 1 次模型调用 skills_loaded: [] 过滤后工具: [skill_data_analysis, skill_text_processing]模型看到只有 Loader 工具判断需要数据分析能力于是调用skill_data_analysis。这个工具返回Command把data_analysis写进skills_loaded。第二次调用时skills_loaded已经变成[data_analysis]过滤后工具增加到 4 个[SkillMiddleware] 第 2 次模型调用 skills_loaded: [data_analysis] 过滤后工具: [skill_data_analysis, skill_text_processing, calculate_statistics, generate_chart]模型这次看到了calculate_statistics用它算出平均值 188.0、最大值 220返回最终答案。整个过程里模型第一次只面对 2 个工具第二次面对 4 个工具而不是一上来就面对全部工具。如果你把工具数量放大到 50 个这个差距会非常明显。验证成功的标志就是日志里这两次调用的工具数量变化2 个变 4 个且skills_loaded从空变成[data_analysis]。如果第二次调用工具数量没变说明状态没写进去或者 Middleware 没读到往下看排查部分。5. 本篇常见错排查5.1 Base URL 填错导致 404 或鉴权失败最常见的错误是把官网地址https://taotoken.net/?utm_source...填进了base_url。Base URL 必须是https://taotoken.net/api不带/v1不带任何查询参数。如果你在日志里看到 404 或者invalid api key先检查这一项。另外确认.env里的 Key 没有多余空格load_dotenv(overrideTrue)要放在读取环境变量之前。5.2 skills_loaded 一直是空工具数量不变如果第二次调用日志里skills_loaded还是[]通常是 Loader 工具没有正确返回Command。检查两点一是Command的update字典里键名必须是skills_loaded跟SkillState的字段名完全一致二是ToolMessage的tool_call_id要取自runtime.tool_call_id写错会导致消息更新失败状态也就带不出来。5.3 Middleware 没生效模型看到全部工具如果日志里根本没有[SkillMiddleware]的输出说明 Middleware 没被注册进 Agent。检查create_agent的middleware参数是不是传了元组(SkillMiddleware(verboseTrue),)注意末尾的逗号。另外确认state_schemaSkillState传对了否则request.state里读不到自定义字段。5.4 工具名冲突或重复注册ALL_TOOLS里如果出现同名工具LangChain 在绑定工具时可能报错或者行为异常。确保每个tool函数的名称唯一。Loader 工具和功能工具不要重名映射表里的技能名也要和 Loader 写入的字符串一致大小写敏感。5.5 模型不调用 Loader 直接编答案有时候模型看到用户问题后不调用 Loader 而是直接凭训练知识回答。这通常是系统提示词没写清楚。把SYSTEM_PROMPT里的规则强调一下当前没有对应功能工具时必须先调用 Skill Loader。如果还是不行可以在提示词里明确列出 Loader 工具的名字降低模型的选择难度。6. 继续复现从拿 Key 到跑通动态工具加载到这里整条链路已经跑通了从 https://taotoken.net/?utm_sourcetaotoken_aicg_blog_end 创建 Key把 LangChain 模型客户端的 Base URL 填成https://taotoken.net/api再配上SkillState、Loader 工具和SkillMiddleware最后用销售数据测试验证了动态过滤生效。TaoToken 负责模型通道Middleware 负责工具过滤两者各司其职。如果你后面要接更多技能只需要在SKILL_TOOL_MAPPING里加一条映射再写一个对应的 Loader 工具Middleware 的代码不用改。工具数量继续涨每次模型调用看到的仍然只是当前技能相关的子集。想直接调模型对话验证通道是否通可以走模型对话入口长期跑编码类 Agent、需要稳定额度的话看 Coding Plan接入过程中遇到鉴权或参数问题去 API Keys 页面和接入文档对照检查。Key 拿到手之后剩下的就是把这篇的代码跑一遍看日志里那两行工具数量从 2 变 4。