资讯动态

LangGraph技能三级加载:多技能Agent的Token优化实践

发布时间:2026/10/19 3:28:27 来源:尧图企业网站定制
2023年底我第一次给Agent技能矩阵做瘦身时遇到的情况是技能数量从3个涨到30个系统提示词里的技能描述越堆越长单次请求的输入token从几百涨到小两千账单数字跟着一路往上跳。那一刻我才意识到LangGraph的Skills体系如果不做加载策略全量注入所有技能描述token成本会随着技能库扩张同步失控。这篇就专门聊聊我现在项目里在用的三级加载方案以及围绕Token优化做的那些取舍和实测。这套方案适用于技能库已经成型、数量上了两位数、或者正在做上下文压缩的LangGraph项目。如果你刚接触Skill也可以先按这个思路组织技能定义后面扩展时不用返工。1. 技能膨胀从一份真实Agent配置看Token是怎么被吃掉的先说场景。我这边有一个客服类的LangGraph Agent前期需求是处理订单查询、退货流程、发票申请、物流跟踪等基础操作。一开始只有4个技能我图省事把所有技能的名称、适用场景、输入输出说明、注意事项全拼进system prompt。效果不错准确率也够。后来需求膨胀陆续加了优惠券核销、地址变更、投诉升级、退款进度、客服转人工、售后审核、评价安抚等技能很快就到了25个。问题接踵而来。最直接的感受是单次请求的输入token肉眼可见地涨。假设每个技能的描述平均长度在80~120 token之间25个技能就意味着2000~3000 token的固定开销。这个数字在单次请求里看起来不夸张但如果Agent内部有多轮工具调用每一轮LLM调用都会重新带上这一段完整描述叠加起来就非常可观。比如一个订单查询流程可能要经历3~4次LLM调用每次多花2500 token一个客户会话跑完就是一万 token 的额外支出。更隐蔽的问题是检索质量下降。技能描述全部堆在系统提示词里模型在处理目标问题时注意力会被大量无关技能干扰。实测下来技能超过15个以后模型把当前问题路由到错误技能的概率明显上升。并不是模型变笨了而是提示词里信息密度太低它不得不在几十个技能里挑一个。这个阶段我意识到不能再用“全量注入”这种简单方案。技能的加载必须和缓存体系一样分层高频的常驻、中频的按需加载、低频的只注册不注入。这也是三级加载思路的来源。三级加载的具体定义是这样的第一级常驻层把所有技能的极简索引放进系统提示词只保留“技能名 一句话触发场景”作用是让模型知道有哪些能力可用但不展开细节。第二级按需层在进入Agent主循环之前通过检索匹配当前用户输入只把命中的3~5个技能的完整描述注入模型上下文。第三级注册层完整技能定义、参数schema、执行函数都放在技能注册表里不随系统提示词注入仅在真正执行该技能时才读取并传参。分级后25个技能的索引开销压到了100 token上下按需注入的3~5个技能完整描述约300~500 token加起来不到全量方案的五分之一。2. 三级的划分边界常驻什么、检索什么、注册什么很多人会问三级加载到底按什么标准划分是不是把描述缩短就完事了实际上没那么简单。每一级解决的是不同层面的问题。2.1 常驻层只放“索引”目的是让模型知道有这号技能常驻层解决的是“技能发现”问题。模型在规划下一步动作时需要知道当前Agent具备哪些能力。如果索引缺失模型可能直接编造一个不存在的工具名或者宁可自己硬答也不调用技能。索引的格式要足够紧凑。我的做法是每条技能只保留技能名尽量短但要可读触发场景一句话例如“订单查询、物流跟踪、发票申请”必要时加一个关键词别名方便模型识别同义表达25条技能索引我用两个token预算来压每条控制在3~6个token整体不超过150 token。下面是实际用过的索引片段示例order_query: 订单状态/物流/配送时间 return_refund: 退货/退款/退货地址 invoice: 发票开票/抬头修改/电子发票 coupon: 优惠券核销/叠加/失效 human_handoff: 转人工/投诉/升级这段文本在主模型里只占不到60 token信息密度很高。模型一眼就能扫完知道遇到订单问题该选order_query遇到退款找return_refund。2.2 按需层核心是“检索节点”在进入主模型前做一次路由按需层的加载不能靠主模型自己判断——等它判断完再注入描述就晚了。正确做法是在LangGraph图结构里加一个前置检索节点用户问题进入图之后先过这个检索节点找出相关的技能集合再把它注入到Agent节点的上下文里。这个节点的判据可以很简单也可以做得很复杂。我在早期版本里用的是规则匹配加同义词表词面命中就选后来技能多了词面碰撞严重换成了embedding向量检索用余弦相似度取TopK。两种方案我都测试过后面会展开讲差异。按需层的核心价值在于它把“所有技能描述”压缩成“当前任务真正需要的技能描述”。比如用户问“我的货发到哪了”命中order_query一个技能就够了完全没必要把退货和发票的描述也塞给模型。2.3 注册层执行期兜底技能的真身在仓库里第三级注册层是最容易被忽略、但其实最重要的。它承担两个职责保存技能全量定义。包括函数实现、参数schema、详细的执行步骤和注意事项。在技能被调用时将完整参数schema追加进工具调用上下文。很多人在做token优化时试图把参数schema也从上下文里拿掉实测会出问题。原因很直接模型在执行工具调用时必须知道该传哪些参数、参数类型是什么否则会生成非法参数导致调用失败。所以注册层在“调用时才把schema传给模型”这一点上要拎得很清楚——平时不占用上下文但调用时该给的信息还是得给。用一张表总结三级的职责和最直观的token特征层级职责注入时机典型token开销常驻层技能索引系统提示词常驻100~200按需层完整描述检索命中后注入300~500注册层函数实现与schema实际调用时追加50~150三条加一起占用的token远小于全量注入而且技能数量越多差距越明显。3. 代码落地LangGraph里三级加载的具体写法直接上代码。下面这套是我在项目里跑通的三级加载实现基于LangGraph的StateGraph核心就三个部分技能注册表、检索节点、注入逻辑。3.1 定义技能注册表技能注册表负责管理所有技能并提供三级各自需要的数据视图。我用了dataclass来定义技能元数据每个技能包含索引摘要、完整描述、执行函数和标签。from dataclasses import dataclass, field from typing import Callable, List, Dict import json dataclass class SkillMeta: name: str summary: str # 一句话触发场景用于常驻索引 description: str # 完整描述用于按需注入 func: Callable # 具体执行函数 tags: List[str] field(default_factorylist) param_schema: Dict field(default_factorydict) # 参数schema class SkillRegistry: def __init__(self, skills: List[SkillMeta]): self._skills {s.name: s for s in skills} def index_text(self) - str: 生成常驻层索引文本 lines [] for s in self._skills.values(): lines.append(f{s.name}: {s.summary}) return \n.join(lines) def get_summaries(self, names: List[str]) - str: 按需提取技能的完整描述 parts [] for n in names: s self._skills.get(n) if s: parts.append(f## {s.name}\n{s.description}) return \n\n.join(parts) def get_schema(self, name: str) - Dict: s self._skills.get(name) return s.param_schema if s else {} def execute(self, name: str, **kwargs): s self._skills.get(name) if not s: raise ValueError(fSkill {name} not found) return s.func(**kwargs)这个注册表本身是纯Python对象没有绑定LangGraph任何组件方便单测。注意我把param_schema单独拎出来这样它在注册层里始终保持“可用但不常驻”的状态。3.2 设计Graph State与三个核心节点接下来是把注册表接进LangGraph。我定义了一个AgentState其中skill_index是常驻索引active_skills是检索命中的技能名injected_skill_text是按需注入的描述。from typing import TypedDict, Annotated, List from langgraph.graph import StateGraph, START, END class AgentState(TypedDict): user_input: str skill_index: str active_skills: List[str] injected_skill_text: str messages: List[dict] response: str然后是三个核心节点load_index负责把常驻索引写进stateroute_skills做检索agent_node注入并按需执行。def load_index_node(state: AgentState) - dict: # 常驻层注入索引文本始终存在 return {skill_index: registry.index_text()} def route_skills_node(state: AgentState) - dict: # 按需层检索根据用户输入挑选相关技能 query state[user_input] matched retrieve_skills(query, top_k3) desc_text registry.get_summaries(matched) return {active_skills: matched, injected_skill_text: desc_text} def agent_node(state: AgentState, llm) - dict: # 组装系统提示词常驻索引 按需注入的技能描述 system_prompt f 你是客服Agent。可用技能索引如下 {state[skill_index]} 当前任务重点使用的技能描述 {state[injected_skill_text]} 请基于以上技能处理用户问题。 messages [{role: system, content: system_prompt}] state[messages] resp llm.invoke(messages) return {response: resp.content}构建图的代码很常规def build_graph(llm, registry): g StateGraph(AgentState) g.add_node(load_index, load_index_node) g.add_node(route_skills, route_skills_node) g.add_node(agent, lambda state: agent_node(state, llm)) g.add_edge(START, load_index) g.add_edge(load_index, route_skills) g.add_edge(route_skills, agent) g.add_edge(agent, END) return g.compile()这样做有个好处检索节点和Agent节点解耦。以后想升级检索策略比如换embedding、加缓存只改route_skills_node一个节点不影响其他部分。3.3 检索函数词面匹配之外的工程细节我在初版里用的检索函数是纯词面匹配后来发现准确性不够因为自然语言表达太灵活。比如用户说“我不想要了要退掉”如果技能库里的退货技能标签是return_refund配了“退货、退款、退换”词面匹配就可能漏掉“退掉”这种变体。所以我在标签里加了一组同义扩展并且在函数里做简单的包含判断def retrieve_skills(query: str, top_k: int 3) - List[str]: scores {} for name, skill in registry._skills.items(): score 0 q query.lower() for tag in skill.tags: if tag in q: score 1 scores[name] score ranked sorted(scores.items(), keylambda x: x[1], reverseTrue) # 只保留至少命中一个标签的技能避免给Agent塞无关描述 return [name for name, sc in ranked if sc 0][:top_k]如果你的技能库规模更大体验要求更高我建议用本地embedding模型把技能标签和完整描述都向量化然后用余弦相似度检索。这一步的效果提升很实际——它能把“退掉”和“退货”拉近到同一个语义空间。代价是多一次embedding计算大概增加几十毫秒延迟。在LangGraph里这其实就是route_skills_node内部的实现替换图结构不用动。4. 实测收益Token降了多少、延迟快了多少写代码是一回事数字才最有说服力。我拿25个技能的真实配置做了AB对比一组全量注入所有技能描述一组走三级加载。固定20条用户问题样本统计输入token和首token延迟。先看输入token的统计差异场景全量注入三级加载节省幅度输入token均值292548383.5%输入token中位数286045284.2%20次请求总输入token58500966083.5%差异主要来自两点常驻层只保留了60 token的索引文本按需层仅注入命中技能的完整描述。20条测试样本里大多数问题只命中1个技能少数命中2~3个所以按需注入平均只有380 token左右。延迟端的改善同样明显。因为LLM首token生成前的prefill阶段要处理完整输入序列输入token减少后这部分耗时显著下降。我用的模型在20次样本上的平均首token延迟从全量注入时的1.8秒降到三级加载后的1.1秒快了近40%。对客服场景来说这个差距用户是能感知到的。成本方面换个算法更容易理解。假设每百万token的价格按中等档位算且每个客户会话平均触发4次LLM调用一次会话全量注入方案约消耗11700 token三级加载方案约1932 token省下来的约9800 token。如果一天跑500个会话一天就省接近500万token。这个量级对预算敏感的项目来说不是小数目。检索质量也需要持续盯。我做的另一个测试是同一批用户问题比较三级加载和全量注入的任务完成成功率。结果是三级加载不仅没有变差反而略好。原因是无关技能不再进入上下文模型被干扰的概率降低了。不过这个结论有前提——检索节点必须足够准一旦检索漏掉该走的技能成功率就会崩。5. 实行三级加载后踩过的坑与值得继续优化的方向不管方案设计得多好落地上总有几个坑。这部分给后来者提个醒也是我自己反复折腾后的沉淀。5.1 检索漏判是最大的稳定性隐患必须设计降级三级加载的命门在检索节点。如果用户输入没命中任何技能标签按需注入层就会变成空字符串Agent只剩一条索引列表很可能不知道怎么回答。我的降级策略是三层兜底在retrieve_skills里设置最低命中阈值只要没有任何标签命中就在注入文本中加上一行“当前没有完全匹配的技能请用通用客服逻辑应答”。如果Agent输出的回答质量低或者用户直接说“你说错了”就触发二次检索扩大top_k范围把更多候选技能的描述注入进去再生成一次。在前端拦截层加同义词兜底词表像“退掉”“不想要了”“取消订单”这类高频表述提前映射到对应技能标签。这个降级链路不能省略尤其在线下灰度阶段检索漏判的比例不是零。5.2 “Token省下来的部分”和“实际省下的钱”不是一个概念三级加载把输入token降了很多但要留意两个隐形成本。第一检索节点自己也要消耗资源。embedding检索需要把用户问题向量化这个计算不经过LLM计费但会占用CPU/GPU时间。第二工具调用的schema在注册层仍然要传如果某个技能参数特别多比如一个create_order有15个字段调用这个技能的那一轮消息token依然会偏高。所以我在设计技能函数时刻意压缩参数个数能用默认值就用默认值把参数schema压到最小必要集合。有一次我为省token把参数schema里的description字段全去掉了结果模型开始猜参数含义生成非法调用重试了两轮才成功。那一轮多花的token比省下来的还多。教训是schema可以精简格式但不能去掉语义信息。5.3 常驻索引的更新频率与一致性技能库不是静态的今天加了一个新技能明天改了描述如果索引没同步Agent就感知不到新能力。我在注册表外层加了一个简单的版本号每次改动技能都会递增然后在load_index_node里检查版本号变了才重新生成索引文本并缓存。这样既保证了新鲜度又避免每次请求都重新拼接索引字符串的微小开销。5.4 后续可以继续做的方向三级加载跑稳之后下一个优化点通常是技能描述本身的精简。完整描述越短按需注入的收益越大。我会拿LLM对技能描述做一轮摘要压缩把执行细则从描述里剥离集中放到注册层的func内部实现里。另外同类技能可以合并成组合技能比如“发票相关”合并开票、抬头修改、电子发票三个子能力为一个技能索引和注入都进一步缩水。还有一个思路是会话级缓存。同一个客户会话内如果用户问题围绕同一个技能检索结果其实可以复用没必要每轮都重新做embedding相似度计算。我在route_skills_node里加了一个按会话ID的LRU缓存实测在连续追问场景下能省掉30%左右的检索开销。三级加载做到这个程度整体效果已经比较理想了。如果你现在正被多技能Agent的token成本困扰不妨按这个思路试着分一层常驻、一层按需、一层注册跑出来的数字大概率不会让你失望。

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

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

免费获取报价 →
↑