做AI Agent开发时间长了,你会发现一个特别隐蔽但特别痛的坑——技能管理。我最早接触Agent项目时也是一个标准的demo级应用,一个Agent挂三四个工具函数跑通就够了。但一旦想把Agent真正放进业务里,技能数量上到十几个、几十个,问题就全来了:技能定义散落在代码、prompt、配置文件里,一个工具改了,连带好几个Agent的行为跟着变,调用情况全靠翻日志猜,成本、耗时、成功率完全抓瞎。
于是我自己动手写了一个给AI Agent用的可视化技能管理器,把所有技能做统一注册、可视化编排、实时监控,用一个面板管住所有Agent的“手脚”。这篇文章就是把这个项目的设计思路、技术选型、核心实现和踩坑记录完整拆给你看,适合正在用FastAPI + LangChain + LangGraph做Agent、并且开始被技能管理问题折磨的朋友。如果你正处在“从0到1搭建AI Agent”或者“Agent怎么扛并发”的阶段,这篇文章里的部分内容可以直接抄作业。
1. 为什么要给AI Agent单独做一个技能管理器
1.1 Agent项目最容易被忽略的“技能失控”问题
很多人做Agent,注意力全在模型选择和提示词工程上,觉得只要模型够聪明,Agent就会干活。实际上真正决定Agent业务价值的是技能——Agent能调用哪些工具、怎么调用、怎么编排、怎么容错。我在多个项目里推进到中后期,技能管理的问题几乎一模一样地冒出来。
首先是技能定义分散。同一个“查订单”的能力,在A项目里写成一个Python函数,在B项目里写成了一个API调用,在C项目里干脆直接写进了提示词的例子里面。某个Agent需要用到它,实现方式全靠复制粘贴,改了一处忘了另一处,排查问题的时候满代码库找人。
其次是缺少统一的调用视图。技能被执行了多少次?成功率多少?平均耗时多少?这个问题在原生Agent里的答案通常是“不知道”。没有调用统计,就没有优化依据,成本失控也没人说得清钱花在哪了。
最后是技能迭代像走钢丝。升级一个技能,影响的是所有引用它的Agent,你根本不知道哪些Agent在用,升级出问题只能回滚整个服务。
我把这种状态类比成家里堆满杂物的工具箱,螺丝、钉子、胶带混在一个盒子里,找东西全靠运气,收拾起来比干活还累。
1.2 技能管理器和“Agent框架”的边界在哪里
这里要先划清一个边界,否则很容易变成重复造轮子。LangChain、LangGraph这类框架解决的是Agent的“思考与行动循环”——模型调哪个工具、工具结果怎么喂回给模型、循环什么时候该停。而技能管理器解决的问题在更下面一层,是技能的“生命周期管理”——注册、启停、鉴权、版本、路由、监控、灰度。
可以这样理解:框架是业务逻辑,管理器是运维平台加配置中心。
这个概念跟业界常说的“AI Agent中台”是同一方向的落地形态。但我不建议一上来就搞宏大的中台架构,那对个人开发者和中小团队来说负担太重。做一个轻量的技能管理器,把核心事务管住,等规模确实大了再往中台演化,这才是比较务实的路径。我这个项目就是朝这个方向走的第一步。
2. 整体架构与关键技术选型
2.1 技术栈为什么选择FastAPI + LangGraph
技术选型时,我先后对比过Spring AI Agent方案和Python系方案。Spring AI的优势是跟Java生态无缝,适合已有Java技术栈的团队。但如果是从0到1做Agent项目,又没有历史包袱,我更推荐Python生态。
核心原因有三点:
- FastAPI天生异步。Agent的技能调用绝大多数是IO密集操作,比如请求LLM接口、查数据库、调第三方REST服务,异步模型能把并发能力顶上去。FastAPI的async/await支持非常自然,压测性能也过得了关。
- LangChain工具协议是事实标准。用
@tool装饰器可以把一个普通函数变成可被LLM感知和调用的技能,接入成本极低,生态里现成的工具集成也很多。 - LangGraph适合做技能编排。Agent的复杂行为本质是一张图:有条件分支、有循环、有并行节点。LangGraph对这类图式流程的支持比单纯LangChain Chain更灵活,还能天然支持人工审批节点。
可视化前端我选了Vue3 + Element Plus + ECharts。Vue3生态成熟,Element Plus的后台组件很齐全,ECharts做监控报表不用重复造轮子。存储方面:MySQL存技能元数据,Redis做技能配置缓存和调用统计缓存,MongoDB存详细调用日志。
2.2 技能管理器的核心模块划分
整个管理器我拆成四个核心模块:注册中心、编排中心、运行网关、观测中心。
- 注册中心:负责技能的登记、描述、版本管理、启停状态。一个技能上线,不是在代码里加个函数,而是在注册中心里创建一条带版本号的记录。
- 编排中心:负责可视化配置Agent调用技能的流程。技能之间的先后顺序、条件分支、重试策略,都在这里通过图形界面配置。
- 运行网关:Agent运行时调用的统一入口。它接收Agent的请求和上下文,根据编排中心的配置把请求路由到对应的技能执行器,同时执行限流、超时控制、审计。
- 观测中心:负责技能调用链路的可观测性。每个技能调用的请求参数、返回结果、耗时、token消耗、错误信息都可以查询,并汇总成实时大盘。
这四个模块的逻辑关系很清晰:注册中心管“有哪些技能”,编排中心管“怎么用”,运行网关管“怎么跑”,观测中心管“跑得怎么样”。
3. 核心功能实现与关键细节
3.1 技能注册Schema设计
技能注册是整个系统的基础,Schema设计直接决定后面所有环节的体验。我设计的技能注册结构大致如下:
{ "skill_id": "order_query", "name": "查询订单状态", "version": "1.0.0", "description": "通过订单号查询当前订单的物流和状态信息", "endpoint": { "type": "python_function", "module": "skills.order", "function": "query_order", "runtime": "asyncio" }, "params": { "type": "object", "properties": { "order_id": { "type": "string", "description": "订单号,必填" } }, "required": ["order_id"] }, "timeout_ms": 5000, "qps_limit": 100, "status": "active", "owner": "team_b" }这里有几个细节我特别强调。
参数描述用JSON Schema格式。LLM在调用技能时需要根据技能描述自动生成参数,JSON Schema是很多模型API原生支持的格式,LLM理解起来最直接。同时前端可视化表单也能基于同样的Schema自动渲染出输入控件,一套Schema两端通用。
endpoint字段区分执行器类型。现阶段我支持三种:python_function直接调用本地异步函数,http_service调用独立的微服务接口,agent_skill嵌套调用另一个Agent的技能。字段里还带了runtime标记,表示这个技能执行是否需要事件循环调度。
版本字段不是装饰品。技能升级时强制version+1,注册中心会保留旧版本的配置和指向,运行网关默认路由到最新版本,但可以按Agent维度指定使用某个历史版本,这样灰度发布的基础就有了。
技能注册到管理器后,用LangChain的@tool协议包一层,就能被Agent正常识别:
from langchain.tools import BaseTool from pydantic import BaseModel, Field from common.skill_registry import registry class QueryOrderInput(BaseModel): order_id: str = Field(description="订单号") @registry.skill("order_query") class QueryOrderTool(BaseTool): name = "order_query" description = "查询指定订单号的物流和状态信息" args_schema = QueryOrderInput async def _arun(self, order_id: str) -> str: return await query_order(order_id)注册中心不是简单的CRUD,它会主动检查技能之间的依赖关系,发现循环依赖会报错;还会检查新版本技能的params和旧版本是否兼容,不兼容时给出警告,避免运行时才发现Agent传的参数不对。
3.2 可视化编排的实现方式
技能注册好之后,接下来就是在可视化界面上编排“Agent干一件事需要走哪些技能”。我这个项目的编排中心基于LangGraph的图结构做了二次封装,配置的本质就是节点和边的集合。
{ "graph_id": "order_service_graph", "version": "3", "nodes": [ {"id": "start", "type": "llm_router"}, {"id": "query_order", "skill_id": "order_query"}, {"id": "check_after_sale", "skill_id": "after_sale_policy"}, {"id": "summarize", "type": "llm_summarizer"} ], "edges": [ {"from": "start", "to": "query_order", "condition": "need_order_info"}, {"from": "start", "to": "check_after_sale", "condition": "need_after_sale"}, {"from": "query_order", "to": "summarize"}, {"from": "check_after_sale", "to": "summarize"} ] }这种配置结构有两个好处。第一,数据库里存的是一份纯JSON描述,前端画布和LangGraph执行引擎可以共用同一份数据,不用各自维护一套模型。第二,图中可以混用普通技能节点、LLM路由节点、SSE流式节点,比如订单量大的时候加一个“人工确认”节点进去,就是一个典型的人机协同流程。
前端画布我用的是vue-flow拖拽组件。左边的技能列表可以拖到画布上,连出箭头表示调用关系,条件写在边上。保存后,后端把这份图配置交给LangGraph的执行引擎加载:
from langgraph.graph import StateGraph, END graph_builder = StateGraph(AgentState) for node in graph_config["nodes"]: graph_builder.add_node(node["id"], skill_executor(node)) for edge in graph_config["edges"]: graph_builder.add_edge(edge["from"], edge["to"]) compiled_graph = graph_builder.compile()这里有个坑:图配置变更后,编译好的LangGraph对象不能热更新。如果直接在运行服务里重新编译,正在执行的任务可能引用到一份半新半旧的图。我的做法是给编译后的图加一个graph_id + version的缓存key,每次运行前先按key取缓存,取不到才重新编译,这样能保证同一个请求全程用同一份图逻辑。
3.3 Agent与技能管理器的运行集成
技能管理器不是独立玩具,它必须要跟真实的Agent运行流程对接。我的集成方式很简单:Agent在处理用户请求时,不再直接调用具体函数,而是调用管理器的运行网关接口。
@app.post("/v1/run_skill") async def run_skill(request: RunSkillRequest): skill = await get_skill_route(request.agent_id, request.intent) if skill is None: raise HTTPException(status_code=404, detail="no matching skill") return await execute_skill_with_timeout(skill, request.params)这个接口做了几件事:根据agent_id和意图解析结果,找到该Agent当前生效的编排图和具体技能节点;从Redis读取技能配置缓存;执行超时控制和限流拦截;记录一条完整的调用日志。
为了让Agent的运行不阻塞在某个慢技能上,所有技能执行都放进事件循环里调度。如果某个技能是同步阻塞函数,就丢进线程池执行,避免它拖死整个Agent的异步循环。这个细节在并发量上来之后差别非常大。
4. 性能与并发:AI Agent 的扛并发方案
4.1 技能调用的性能瓶颈到底在哪
很多人在问“AI Agent怎么扛并发”,在技能管理器这个场景里,我实测下来最大的瓶颈不在于模型本身,而在于技能调用的IO链路。
Agent的一次完整请求,通常经历“LLM推理→调技能→拿到结果→再喂给LLM→再推理”的循环。一次业务对话可能要调2~5次技能,每次技能调用背后又是一个外部API请求。这些请求如果写得不好,就是串行的,一个慢技能会把整条Agent链路拖慢。更麻烦的是,技能调用往往没有连接复用,每次new一个HTTP客户端,TCP握手和TLS握手的开销比业务逻辑本身还大。
我之前优化过一个技能执行器的调用链路,只把内部HTTP请求改成复用httpx.AsyncClient连接池,p95耗时就下降了接近40%,这个优化几乎不花钱,效果极其显著。
4.2 异步、缓存、限流三板斧
在技能管理器里,扛并发靠的不是堆机器,而是把异步、缓存、限流这三件事做扎实。
异步化改造是第一步。FastAPI的async接口 +httpx.AsyncClient并发调用外部技能服务,事件循环里同一时间可以挂几百个IO等待。如果是CPU密集型的本地技能,比如文档解析、向量化,用asyncio.to_thread丢进线程池,别在事件循环里做重计算。
缓存策略我分了两层。第一层是技能配置缓存,技能注册信息和编排图配置很少变化,放在Redis里可以减少数据库QPS。第二层是技能结果缓存,针对同参数、同技能的重复查询,比如多个用户查询同一个热门订单的状态,可以做短时结果复用。但缓存不可滥用,实时性要求高的技能,比如余额查询、库存扣减,绝对不能缓存。
限流设计是为了保护技能执行器,避免流量尖峰把下游服务打挂。我给每个技能配置了独立QPS上限,采用Redis + Lua脚本实现令牌桶,这是比较成熟的方案:
import redis r = redis.Redis(host="redis_host", port=6379, db=0) LUA_TOKEN_BUCKET = """ local key = KEYS[1] local limit = tonumber(ARGV[1]) local current = tonumber(redis.call('GET', key) or limit) local allowed = 0 if current > 0 then redis.call('DECR', key) allowed = 1 end redis.call('EXPIRE', key, 1) return allowed """ def check_qps_limit(skill_id: str, limit: int) -> bool: return r.eval(LUA_TOKEN_BUCKET, 1, f"qps:{skill_id}", limit) == 1限流不能只看管理器这一个点,真正扛并发的时候,执行器自身的连接池、超时时间、重试策略都要配套。管理器只做入口控制,执行器要保证自己不会被一个异常请求拖入长时间阻塞。
4.3 从管理器视角看一套可落地的并发水位
我用自己的这台管理器跑过压测,环境是8 vCPU / 16GB内存的容器,uvicorn起了8个worker,执行器都是本地异步函数,不做外部API调用的情况下,单机稳定支撑1500 QPS的入站请求,此时CPU占用约70%,再往上走就会开始排队,响应时间明显上升。
但实际业务中不可能这么理想。一旦技能执行涉及外部LLM调用,QPS的瓶颈就转移到了模型API侧的并发限制和响应速度。这时候管理器的作用就是做好分流,不能让慢技能占满worker导致快技能也跟着排队。
我实践下来的一个经验是:慢技能和快技能要隔离。耗时超过3秒的技能,不要直接挂在FastAPI的请求处理链里,而是投递到arq或Celery任务队列异步执行,Agent通过轮询或回调拿到技能执行结果。这样快请求永远不被慢请求拖累,整体吞吐才能上去。
5. 常见问题与排查技巧实录
5.1 技能注册成功但Agent调用不到
这是我被问得最多的问题,几乎每个接入者都会踩一次。技能配置明明在管理面板里是“启用”状态,但Agent跑起来就是找不到。
排查思路基本就两条。第一,查看Agent加载技能配置时用的是不是缓存里的旧值。技能管理器会把配置缓存到Redis,注册中心更新后如果没做缓存失效,Agent拉到的就是半分钟前的旧配置。第二,确认Agent的版本选择器指向的是否为最新技能版本。
我的解决方案是在技能更新接口里强制做“版本号+缓存刷新”两步操作,版本号用于链路标记,缓存刷新用Redis的DEL命令把对应key删掉,下次请求自动回源数据库。这里有个细节,缓存刷新要做成异步广播,不然高并发下多个worker同时回源数据库会瞬间打出一条查询峰值。
5.2 LangGraph图更新后运行还是旧流程
这个问题跟技能缓存类似,但坑得更隐蔽。LangGraph的编译对象本身是静态的,你在面板上改了编排图,后端如果还在用旧的编译结果,Agent的行为就是老的。
我的处理手段是在图配置文件里加generation字段,每次保存编排图都递增;Agent的每次技能路由请求都携带图的generation号,运行网关比对缓存里的编译对象generation不对,就重新编译并放进缓存。编译操作不便宜,所以必须加读写锁,防止并发请求同时触发编译导致CPU飙高。
5.3 高并发下Redis连接数被打满
技能配置缓存、调用统计、限流计数全都用Redis,不做好连接管理很容易把Redis连接数跑满。我见过一个同事把限流逻辑写在业务循环里,每次请求都新建连接,最后Redis直接拒绝连接。
正确的做法是全局复用Redis连接池,redis.Redis对象的线程安全性是可靠的,本身就是池化的连接管理。如果还想继续优化,把多个读写合并成pipeline,一次网络往返处理完一批操作。技能调用统计这种高频写入场景,可以改成先在本地缓冲聚合,每100ms批量写入一次Redis,减少写放大。
5.4 技能超时但Agent还在傻等
给技能设置超时是基本操作,但只设置超时不处理超时结果,Agent会卡在那里,导致整个对话链路挂起。
我在运行网关里用asyncio.wait_for包裹技能执行,超时后不是简单地抛异常,而是返回一个结构化的“技能超时”错误对象,里面带skill_id、finish_reason=timeout、elapsed_ms。Agent拿到这个对象后可以走兜底分支,比如换一个相似技能,或者直接向用户回复“查询超时请稍后重试”,而不是整个流程中断。
下面的表格我把这几个高频问题整理成了速查版本,方便你直接照着排查:
| 问题 | 直接原因 | 排查入口 | 解决方案 |
|---|---|---|---|
| Agent调用不到已启用技能 | 配置缓存未刷新 | Redis查询技能配置key | 技能更新时DEL缓存key,强制回源 |
| 图更新后仍走旧流程 | 编译对象未更新 | 检查generation缓存一致性 | 图配置版本号化管理 |
| Redis连接被占满 | 请求频繁创建新连接 | 查看Redis client list | 全局连接池 + pipeline批量操作 |
| Agent卡在慢技能上 | 超时后无兜底策略 | 查看调用日志finish_reason | asyncio.wait_for + 结构化超时错误 |
| 技能版本升级引发行为突变 | 新版本参数不兼容 | 对比新旧版本Schema | 注册时自动做兼容性检查,警告并回退 |
6. 经验总结与后续扩展方向
6.1 做一个技能管理器的最小闭环
如果你也想做一个类似的技能管理器,我建议先不要被可视化拖拽、大盘监控这些花哨功能诱惑,第一版做一个最小闭环就够了:技能注册 + 运行网关 + 一个简单的技能列表页面。
技能注册支持录入技能的基本信息、参数描述、执行器地址。运行网关负责把Agent的意图路由到对应的技能执行,并且记录调用日志。列表页面能看每个技能的基本状态和最近调用情况。这套闭环跑通了,你才真正理解“技能管理是做什么的”,后面往上加编排中心、观测中心才有依据。
我见过很多项目死在第一步,一上来就想做拖拽编排、多租户、技能市场,三个月出不来能用的东西,最后整个推倒重来。做工具类系统最重要的是先把主干走通,再长枝叶。
6.2 后续可以扩展的方向
技能管理器这个方向扩展空间非常大,我接下来的规划主要有三个方向。
技能版本灰度。目前是全局按最新版本路由,后面要做成可按Agent维度指定技能版本,配合流量切分实现技能升级的灰度发布,一个Agent群体验新版本,另一群保持旧版本,观察指标后再全量放量。
技能执行器插件化。现在的执行器耦合在项目内部,后面想做一套插件协议,让Java写的Spring AI Agent、Node.js写的脚本都能作为技能接入管理器,真正往“中台”方向走。
技能成本分析。把每个技能调用的token消耗、外部API费用、耗时指标汇总成一个成本大盘,按Agent、按技能维度排序,让老板能一眼看出来钱花在哪里,这是技能管理器真正的业务价值。
6.3 个人实操体会
最后说一点我做这个项目最真实的体会。技能管理器看起来是个工具,本质上其实是工程化思维的产物。Agent能力越强,越需要规范化的技能生命周期管理,否则代码库迟早变成一团乱麻。
我在实际使用中发现,把这个管理器搭起来之后,最大的改变不是“管理技能”这件事变容易了,而是团队协作模式变了。以前加一个技能要改代码、走发布流程,现在直接在面板里注册,配置好参数描述和限流策略就能上线;以前排查问题要一堆人围着日志猜,现在调用链路一目了然。这种从“写代码实现功能”到“配置化运营能力”的转变,才是Manager带给我的真正价值。
最后再分享一个小技巧:技能描述文案一定要认真写。很多人觉得技能注册时描述字段随便填一下就行,实际运行时LLM对技能描述的语义理解直接决定它能不能在正确时机调用技能。描述写得太笼统,模型就不知道该用;写得太具体,又限制了泛化能力。我后来专门建了一份技能描述写作规范,要求每个技能描述里包含“功能一句话说明、适用场景、典型参数示例”三要素,之后Agent的技能路由准确率提升了大概15%,这是完全不用花钱就拿到的高收益优化。