资讯动态

Agent技能三级加载与Token优化:从系统提示词膨胀到精准加载

发布时间:2026/10/19 3:20:21 来源:尧图企业网站定制
1. 项目背景技能多到塞不下是时候重新思考加载方式了做Agent应用做到一定阶段你会发现真正的瓶颈往往不是模型能力而是“技能”太多之后Prompt塞都塞不下。我在某个实际项目中一路从10个工具加到40多个技能系统提示词直接膨胀到接近2万token每次请求一半的tokens都花在“告诉模型它有哪些技能”上而且效果还越来越差——模型面对一长串工具清单经常挑错工具甚至漏掉明显该用的技能。这个标题里提到的“Skills 三级加载与Token优化”说白了就是我那时候摸出来的一套解法。三级加载指的是把技能的管理从“全量常驻”改成“分层动态”第一级技能索引Name 一句话摘要常驻系统提示词体积压到最小。第二级技能完整定义参数、逻辑、约束、触发条件按需加载命中哪个就加载哪个。第三级技能执行所需的代码/配置/资源真正要用到的方法体运行时拉取。这套东西做出来之后我的系统提示词从近2万token砍到了2000出头技能数量反而加到了60多个模型的工具选择准确率还明显提升了。这篇文章我会把完整的架构思路、核心代码、参数选型逻辑和踩坑记录都拆出来适合那些正在被“技能多了之后上下文爆炸”困扰的Agent开发者无论你是用LangGraph还是其他编排框架这套分层思路都能直接迁移。2. 为什么三级加载能省钱Token计费的本质逼迫你分层2.1 多一层索引少一半浪费先说一个可能被很多人忽略的事实LLM的输入Token计费是按“实际送入模型的Prompt内容”来算的不是按你“定义了多少技能”来算。所以技能管理本质上是一个“信息压缩 选择性展开”的问题——你要让模型在决策时能感知到所有技能的存在但又不能把每个技能的完整文档都塞进上下文。我打个比方。全量加载就好比你走进一间上百平米的仓库所有工具都摊在地上你一眼能看到全部但找一把扳手要在一堆杂物里翻半天而且仓库租金token按面积算。三级加载则是把仓库改成“货架 中央目录”目录上写着“A区3排扳手类”你按目录走到对应货架再抽出需要的工具。模型跟这个机制是一样的——它首先看到的是技能目录一级当它判断某个目录条目可能有用时再调取详细说明书二级最后才真正执行技能代码三级。这里面有一个非常关键的权衡点一级索引的内容必须足够精炼通常每个技能只保留“技能名 一句话场景描述 触发关键词”这样即使有60个技能索引总长也控制在1500-2500个token以内。二级完整定义才是真正展示技能参数细节和约束条件的地方只在模型选择该技能后注入。三级则不占上下文它只是运行时加载的资产。2.2 从两级到三级中间发生了什么实际上很多团队做过“两级加载”的尝试要么全量塞技能说明要么只放技能名让模型猜。走极端的后果都很明显——全量塞token爆炸且模型注意力被稀释只放名字模型经常出现幻觉式的工具调用参数乱填。三级方案的核心改进在中间加了一个“检索命中”的环节。具体流程是这样用户的请求进来以后先带着一级索引做一个快速语义匹配这一步可以用embedding计算也可以用简单的关键词规则甚至让模型自己选成本不同、精度不同把候选技能范围从60个缩小到3-5个然后再把这3-5个技能的二级完整定义注入Prompt。这样模型永远只需要面对“已经被筛过的少量技能”决策难度大幅下降。值得强调的是这个“预筛”环节有两种落地方式——基于规则的比如关键词命中、正则匹配和基于模型的用一次轻量调用让模型输出相关技能ID。前者几乎零成本但召回率有限后者多花几百token但能理解语义层面的相关性。我在生产环境的选择是规则预筛 模型复核。先用关键词规则粗筛到10个以内再让模型从这10个里挑出最相关的3个兼顾成本和精度。3. 核心设计三级加载的状态机与数据结构3.1 技能注册表从自由散漫到统一规范要支撑三级加载首先得把技能的定义规范化。不能再用一串松散的Python函数到处散落而是要建立统一的技能注册表每个技能都有明确的“索引条目”“完整描述”“执行体”三块。我比较推荐用dataclass定义技能描述结构或者直接用yaml维护关键是要让“索引层”和“完整层”在物理上分离存两套数据。下面是技能注册表的核心结构示例from dataclasses import dataclass, field from typing import Callable, Optional, Dict, Any dataclass class SkillMeta: 一级索引最精简的技能条目最终会常驻系统提示词 skill_id: str # 技能唯一ID name: str # 展示名称 one_liner: str # 一句话摘要(50字) trigger_keywords: list[str] field(default_factorylist) # 触发关键词 enable: bool True # 是否启用 dataclass class SkillDefinition: 二级定义完整的技能说明命中后才注入Prompt meta: SkillMeta description: str # 完整能力描述 parameters: Dict[str, str] # 参数字段及说明 constraints: list[str] field(default_factorylist) # 约束条件 examples: list[str] field(default_factorylist) # 调用示例 usage_tips: str # 使用技巧 dataclass class SkillExecutor: 三级执行体真正的可执行逻辑运行时才加载 definition: SkillDefinition handler: Callable[..., Any] # 实际执行函数 assets: dict field(default_factorydict) # 需要的资源文件或配置这种分离设计带来的一个直接好处是新增技能的成本变得非常低。你只需要写一个SkillMeta和SkillDefinition执行体可以挂到任意Python函数上注册表会自动把它纳入三级体系。我以前要写完整工具类现在一个技能从写逻辑到接入系统半小时搞定。3.2 技能池的管理用字典做索引用LRU做缓存技能池的管理分三个维度静态注册、动态加载、运行时缓存。静态注册是在应用启动时把所有技能读入内存但只加载第一级索引到提示词里动态加载是在请求处理流程中按需把技能提升到二级、三级状态缓存则是控制“哪些技能近期被用过不用反复加载完整定义”。我维护技能池用的是这样一个管理器class SkillRegistry: def __init__(self): self._skills {} # skill_id - SkillExecutor self._hot_cache LRUCache(maxsize10) # 近期活跃技能缓存 def register(self, executor: SkillExecutor): self._skills[executor.definition.meta.skill_id] executor def get_index_entries(self) - list[SkillMeta]: 返回所有一级索引条目用于构建常驻系统提示词 return [s.definition.meta for s in self._skills.values() if s.definition.meta.enable] def get_full_definition(self, skill_id: str) - Optional[SkillDefinition]: 命中后获取二级完整定义 if skill_id in self._hot_cache: return self._hot_cache.get(skill_id).definition executor self._skills.get(skill_id) if executor: self._hot_cache.put(skill_id, executor) return executor.definition return None def get_handler(self, skill_id: str) - Optional[Callable]: 执行时获取三级执行体 executor self._skills.get(skill_id) return executor.handler if executor else None这里的LRU缓存是Token优化的重要工具。同样的技能如果在一段对话中被反复使用二级定义不用每次重新注入只要在首次命中时输出一次完整说明后续对话中模型已经“见过”这个技能了上下文里天然就有——这就是会话级技能缓存的原理。3.3 为什么三级而不是更多级层数与应用场景的匹配有人可能会问为什么不搞四级五级比如再加一个“技能分组层”我在设计时也确实犹豫过但最终没有加更多层级原因是每一层都是有代价的——层数越多路由决策的延迟越高而且模型在判断“该去哪一层找什么”时更容易出错。三级的分法正好对应模型决策的三个自然阶段“我不知道它存不存在”一级索引→“我确认它存在且我需要它”二级定义→“我要真正调动它”三级执行。超过三层就进入了工程上为了抽象而抽象的领域反而增加系统复杂度和排查难度。如果技能数量真的多到几千个那时候你需要优化的不是层数而是索引本身的检索方式——比如引入向量数据库做更精细的语义检索而不是继续堆层级。4. Token优化的三个关键切口听起来不复杂做起来全是细节4.1 切口一一级索引的“一句话描述”要敢砍这一步是整个token优化里收益最大的。很多开发者写技能摘要时舍不得删总想把功能边界、注意事项、示例通通写进摘要。结果索引层的描述跟完整定义区别不大token省不下来。我的硬性标准是一级索引条目不超过50个中文字符必须包含“这个技能是干嘛的”和“什么情况下你会想起它”。举个例子坏写法订单查询技能用于查询用户在系统中创建的所有订单信息支持按订单号、商品名称、下单时间、订单状态等多维度筛选返回订单列表及其详情请注意时间范围默认30天状态包含待支付、已支付、已发货、已完成、已取消等。好写法订单查询查订单支持单号/商品/时间/状态筛选。用户问“我的订单/买了什么”时触发。好写法的信息密度高得多。模型通过“买什么”这个词就能建立联想至于详细的参数规范等命中后加载二级定义再去读。4.2 切口二按命中范围动态拼接二级描述二级描述的Token消耗是浮动的系统提示词里只为它预留一个占位符真正的内容根据命中结果动态填充。核心实现思路如下def build_skills_prompt(user_query: str, registry: SkillRegistry) - str: # 1. 一级索引常驻先从注册表拿全部meta idx_text 可用技能清单仅作判断用详细说明后续提供\n for meta in registry.get_index_entries(): idx_text f- {meta.name}: {meta.one_liner}\n # 2. 命中筛选 candidates rule_based_match(user_query, registry) top_ids model_select(candidates, user_query, k3) # 模型复核 # 3. 动态加载二级定义 detail_text \n命中技能详细说明\n for sid in top_ids: definition registry.get_full_definition(sid) detail_text render_definition(definition) return idx_text detail_text这个流程的精髓在于输入给模型的Prompt分为两个区块索引区是静态的每次都在详情区是动态的只有命中才展开。我在LangGraph里是把这两个区块分别塞到不同系统消息区块的模型在上下文窗口里能清楚地看到“通用技能索引”和“当前任务相关技能详解”两个部分。4.3 切口三执行结果要“瘦身”后再送回上下文这个切口很容易被忽略。很多技能执行完返回了超长结果比如一个数据查询技能返回了一个包含50行记录的JSON数组全量塞进上下文几十个技能累计下来又是大几千token。也因此在设计中需要加入结果后处理逻辑——只保留模型完成当前任务需要的信息。我的做法是给每个技能加一个postprocess钩子在技能返回结果后、送入上下文前执行精简。比如订单查询技能完整结果是明细表但后处理只保留“订单编号、状态、金额”三个核心字段其他字段截断。这样调用链后面的模型节点读到的就是高度精炼的中间结果token消耗显著降低而且幻觉反而少了——因为多余字段不会干扰注意力。def _postprocess_result(skill_id: str, raw_result: str, max_tokens: int 800) - str: 结果瘦身截断 去嘈杂字段 结构化摘要 ... return trimmed_result5. 代码实战基于该框架构建三级加载的完整流程5.1 环境准备与依赖前面讲完理论下面直接上可以跑的代码。这个项目我用的是该框架的最新版本即标题对应的这个版本重点需要安装的依赖如下pip install -U langgraph pip install -U langchain-openai pip install -U numpy我的建议是尽量用最新版本因为这个框架迭代很快旧版本里Graph API和State的写法差异较大照着新写法能少踩不少坑。这里也顺便提醒一句依赖版本锁定对复现很重要——建议在requirements.txt里记下确认能跑通的版本号后面维护才不会有“我本地正常部署就报错”的灵异事件。5.2 定义状态结构与技能池先定义Agent的状态结构。在这个框架中State是贯穿整个Graph流程的数据载体。我们设计状态为消息列表加一个技能命中记录字段from typing import TypedDict, Annotated, Sequence import operator from langgraph.graph import StateGraph, END from langchain_core.messages import SystemMessage, HumanMessage class AgentState(TypedDict): messages: Annotated[Sequence[dict], operator.add] hit_skills: list[str] # 本次命中的技能ID skill_outputs: dict # 技能执行结果缓存hit_skills字段很关键它记录本轮请求命中了哪些技能ID下游节点可以直接引用比如在技能执行前先从skill_outputs检查是否已缓存结果避免重复调用。接着初始化技能池注册几个示例技能registry SkillRegistry() # 注册订单查询技能 order_meta SkillMeta( skill_idorder_query, name订单查询, one_liner查订单支持单号/商品/时间/状态筛选。用户问我的订单/买了什么时触发。, trigger_keywords[订单, 买了, 购物, 订单状态, 订单号], ) order_def SkillDefinition( metaorder_meta, description根据用户提供的筛选条件查询订单列表返回订单号、商品、金额、状态、时间等字段。, parameters{ order_id: 订单编号(可选), status: 订单状态(可选): pending/paid/shipped/completed/cancelled, date_from: 起始时间格式YYYY-MM-DD, date_to: 截止时间格式YYYY-MM-DD, }, constraints[必须登录用户自己的订单, 默认查询近30天], examples[查询最近完成的订单, 订单号ORD1234的状态是什么], ) def order_handler(input_data: dict, assets: dict) - str: return 订单查询结果: ORD1234 已发货 金额¥299 registry.register(SkillExecutor(definitionorder_def, handlerorder_handler)) # 注册库存查询技能 stock_meta SkillMeta(...) stock_def SkillDefinition(...) def stock_handler(input_data: dict, assets: dict) - str: ... registry.register(SkillExecutor(definitionstock_def, handlerstock_handler))5.3 核心节点技能路由、技能执行与回答生成有了技能池后接下来是Graph的三个核心节点。首先是技能路由节点——它负责读取一级索引、做命中筛选、并输出二级详细描述注入上下文def route_skill_node(state: AgentState, registry: SkillRegistry) - dict: last_user_msg state[messages][-1] if isinstance(state[messages][-1], str) else state[messages][-1][content] # 构建系统提示常驻索引 动态详情 system_idx 你是购物助手。你可以使用以下技能帮助用户 for meta in registry.get_index_entries(): system_idx f\n- {meta.name}: {meta.one_liner} # 命中筛选 hit_ids filter_skill_by_llm(last_user_msg, registry, k3) full_def_text for sid in hit_ids: definition registry.get_full_definition(sid) if definition: full_def_text f\n技能详情[{definition.meta.name}]: {definition.description} 参数: {definition.parameters} system_msg SystemMessage(contentsystem_idx \n\n full_def_text) return {messages: [system_msg], hit_skills: hit_ids}这里有一个值得注意的细节系统消息的构建被拆成了两部分。索引区不管是否命中都存在详情区则随时可能为空。这种做法在Graph的调试中尤其方便——你可以在追踪日志里直接看到哪一部分是静态的、哪一部分是动态注入的。filter_skill_by_llm我是这么写的def filter_skill_by_llm(user_query: str, registry: SkillRegistry, k: int 3) - list[str]: candidates [] for meta in registry.get_index_entries(): for kw in meta.trigger_keywords: if kw in user_query: candidates.append(meta.skill_id) break # 如果关键词匹配到的候选不足k个再用LLM补选 if len(candidates) k: llm ChatOpenAI(modelgpt-4o-mini, temperature0) prompt f用户问题: {user_query}\n技能列表: {[m.name : m.one_liner for m in registry.get_index_entries()]}\n输出最相关的技能ID(逗号分隔): resp llm.invoke(prompt).content for sid in resp.split(,): if sid.strip() in registry._skills and sid.strip() not in candidates: candidates.append(sid.strip()) return candidates[:k]然后是技能执行节点。这一步会调用三级执行体并把结果做瘦身def execute_skill_node(state: AgentState, registry: SkillRegistry) - dict: output_cache dict(state.get(skill_outputs) or {}) for sid in state.get(hit_skills, []): handler registry.get_handler(sid) if handler: raw handler({}, registry) output_cache[sid] _postprocess_result(sid, raw, max_tokens500) return {skill_outputs: output_cache}最后是回答生成节点它只需要结合“原始问题 技能执行结果”生成最终回复不再依赖一级索引和二级详细定义进一步减少上游噪音对生成的影响def generate_answer_node(state: AgentState) - dict: skill_results state.get(skill_outputs, {}) result_block json.dumps(skill_results, ensure_asciiFalse) prompt f 用户问题{state[messages][-1].content} 技能执行结果{result_block} 请基于以上结果给出简洁、友好的回答。 llm ChatOpenAI(modelgpt-4o, temperature0.3) answer llm.invoke(prompt).content return {messages: [{role: assistant, content: answer}]}5.4 构建图与条件编译上面三个节点放在一起组成一个典型的顺序链路“路由→执行→生成”。但还需要加一个条件判断如果没有任何技能命中就直接走生成不再做执行。这一步在Graph里用条件边实现graph StateGraph(AgentState) graph.add_node(route_skill, lambda s: route_skill_node(s, registry)) graph.add_node(execute_skill, lambda s: execute_skill_node(s, registry)) graph.add_node(generate, generate_answer_node) graph.set_entry_point(route_skill) def has_skill(state: AgentState) - bool: return len(state.get(hit_skills, [])) 0 graph.add_conditional_edges( route_skill, has_skill, {True: execute_skill, False: generate} ) graph.add_edge(execute_skill, generate) graph.add_edge(generate, END) app graph.compile()这里的add_conditional_edges是路由的核心。如果hit_skills为空说明用户的话跟任何技能都不匹配直接让模型自由回答即可避免白白浪费一次技能执行和一轮结果注入。实际跑一轮的效果如下resp app.invoke({ messages: [{role: user, content: 帮我查一下订单ORD1234}], }) print(resp[messages][-1][content]) # 输出: 你的订单ORD1234目前已经发货金额为299元。用追踪工具看这一次调用的token明细整个流程只有一头一尾两次LLM调用中间技能执行不涉及模型总token比原先全量技能加载时低了60%以上。6. 常见问题与排查技巧实录6.1 技能命中率低索引描述与用户表达对不上出现这个问题的典型症状是用户明确提到了技能的某个功能但hit_skills为空或只命中无关技能。排查时先看一级索引的trigger_keywords是不是太窄了。比如订单查询用户可能说“我的快递到哪了”你的关键词里只有“订单”“买了”自然就漏掉了。我的经验是在trigger_keywords里多收口语化的同义词不要只放书面语。另外规则预筛只做粗排最终决定权在LLM复核但复核Prompt里的技能列表曝光度也很重要——列表越短、越清晰模型命中越准。如果每次候选列表都乱七八糟说明规则层就要加宽召回。6.2 二级定义注入导致上下文太满当命中技能较多时多个二级定义拼在一起可能也会占用大量空间。这种情况下的解决思路是限制并发命中数。我在生产环境里把k3调成了k2准确率反而提升了——因为2个技能的定义足够覆盖绝大多数场景而且上下文被压缩后模型更聚焦。如果确实有多个技能并列重要可以尝试把定义再压缩提炼出“参数列表 3条核心约束”其他的放进执行阶段查。6.3 技能执行结果很大连带后续回答也变啰嗦这个问题跟_postprocess_result的实现密切相关。最开始我偷懒不做瘦身技能返回多长上下文就塞多长结果模型在生成回答时把一些无关字段也顺嘴说了出来比如把内部字段名读给用户听。加了后处理之后明显好转。建议所有执行结果都设置明确的max_tokens上限超过部分用截断加省略号或者结构化提取关键字段。6.4 缓存失效导致技能描述反复注入这个问题的场景是用户先问了一遍订单查询然后又问了一遍但第二次请求时技能详细描述又注入了一遍浪费了token。排查方向看LRU缓存的键设计——我按conversation_id skill_id做缓存键确保同一会话内能复用。另外还有一个细节如果Graph状态里设置了hit_skills字段但只在单次请求内有效跨请求的会话状态如果没做持久化缓存当然会失效。确保你的状态管理和LRU缓存用的是同一个会话ID维度。7. 扩展思路与后续优化方向三级加载做好了以后后续还能往几个方向延展。第一个是技能索引的向量化检索——当技能数量超过100个纯粹靠关键词匹配的命中率会下降这时可以给每个技能的一级索引生成embedding请求进来先做向量相似度召回top20再走LLM精确筛选召回率会好很多。第二个是技能热度的动态调整——经常被命中的技能可以提升到常驻二级状态长期不用的技能降级到更冷的位置这需要维护一套热度统计比固定层级灵活。第三个是技能组编排——把相关技能打包成组合技能命中一个就自动联动其他技能省去多次路由。我在内部的探索中已经在做技能组的“组级索引”每一组用一个索引条目代表命中了再展开组内详细定义这也是在向多级缓存系统演进。如果你正在做的项目也遇到技能多、token贵、模型选择性下降这三重问题先别急着换更大窗口的模型把上面这套三级加载机制落地大概率能省出一个数量级的成本。代码已经贴在这里了照着改一改注册表就能用起来。

读完文章,也想定制专属网站?

尧图设计师 24 小时内与您沟通定制方案

免费获取报价 →
↑