从模型到业务,Agent最终卡在“够不着”这一步
过去大半年我一直在做AI Agent相关的落地项目,Demo跑通很容易,喂给大模型一个系统提示词、挂上几个工具,它就能完成看似不错的任务闭环。可真要把它接到线上业务里,问题就全暴露了:Agent调用工具超时了怎么办?多个Agent同时在线,用户请求到底该发给谁?执行到一半模型“犯迷糊”怎么回滚?用户问为什么这次没成功,你能拿出什么证据?
这些问题本质上是同一个:Agent的能力很强,但它“触达”真实业务环境的能力很弱。我做了一个叫Agent-Reach的框架来解决这件事,简单说,它是一层位于LLM与业务系统之间的“智能体触达层”,负责把请求精准路由到最合适的能力节点、把每一次执行过程完整记录下来、在失败时给出体面的兜底方案。这篇文章把Agent-Reach的设计思路、核心模块、实现细节和踩坑实录完整整理出来,适合正在做Agent工程化落地、被“模型很强但业务接不住”折磨过的开发者和架构师参考。
1. 项目定位与设计思路:Agent-Reach到底解决什么问题
1.1 从Demo到生产,隔着三层“触达鸿沟”
先说个大前提:现在的Agent框架并不缺,LangChain、AutoGen、CrewAI等各有拥趸,模型能力也在快速迭代。但我观察到一个规律,团队用这些框架做POC都很快,一上生产就难受,关键是三件事没打通。
第一层是能力触达。大模型本质上是文本进文本出的函数,它没法主动调你的订单系统、CRM、工单平台,必须靠工具调用(Function Calling)或让模型生成结构化调用来间接完成。这中间的协议、鉴权、超时、幂等等问题,框架不会替你考虑。
第二层是路由触达。当你的系统里有十几个Agent、几十个工具节点时,用户的自然语言请求应该触达哪一个能力节点?靠提示词硬塞是塞不进去的,靠人工分流又退回了老路。这一层解决不好,Agent一多系统就乱。
第三层是信任触达。生产环境不允许“黑箱”。用户不会因为你说“我是AI所以我可能犯错”就接受错误结果,业务方需要知道Agent每一步干了什么、调用了哪个工具、输入输出是什么、为什么走这条路。
Agent-Reach的定位非常明确:不去重造一个新的Agent内核,而是把这三层触达问题收敛到一个独立的中间层统一解决。你可以把它理解成微服务架构里的API网关加消息总线,只不过这里流转的是自然语言请求和Agent执行轨迹。
1.2 四个核心设计原则
做这个框架之前,我给自己定了四条铁律,后面所有实现都围着它们转。
协议先行,能力即Schema。所有能被Agent调用的能力(API、数据库查询、人工交接、工作流触发器)都必须注册成统一的Schema描述:名称、功能描述、入参JSON Schema、鉴权方式、超时设置。Schema做得好的话,大模型天然能理解,不需要额外训练。
路由分离,不跟模型推理耦合。路由决策不依赖主对话模型,而是独立的路由组件,基于能力描述的语义相似度加规则兜底来做。这样路由便宜、快、可单独调优。
追踪贯穿,每一次触达都有案可查。从用户请求进入系统开始,生成全局唯一的request_id,所有Agent执行节点都往追踪流里追加事件。上线后能回放、能统计、能定位问题。
兜底必出,没有结果的调用就是事故。任何一次Agent执行,要么正常返回业务结论,要么走超时降级、重试退避或人工交接,绝不允许让用户面对一个空白的等待。
1.3 它不是什么
也得说清楚边界,避免大家乱用。Agent-Reach不适合做单Agent的简单问答,那种场景你直接用提示词工程就行,没必要上个中间层。它也不适合做纯RAG知识库问答,那些问题重点是检索效果而不是触达编排。它最适合的结构是:多个业务能力节点 + 自然语言入口 + 必须可追溯的执行过程,比如智能客服、内部运营助手、数据分析Agent、自动化运维工单系统这类场景。
我最早做Agent-Reach就是在客服场景里,当时手上有十几个系统API,老办法是在提示词里列一堆工具让模型自己选,结果模型选错工具、参数传错格式的频次高得离谱,更别提排查问题时候的痛苦了。后面把“选工具”这件事从模型推理里拆出来,单独做路由层,效果立刻不一样了。
2. 核心架构与模块拆解
2.1 能力注册中心:把业务API“收编”成Agent看得懂的协议
Agent-Reach最底层的东西不是模型,是注册中心。所有业务能力都要在这里登记注册,注册的格式直接决定模型能不能准确理解、路由能不能有效匹配。
每个能力节点的Schema我这样定义:
{ "name": "order_query", "description": "根据订单号查询订单状态、物流信息和支付信息。适合用户询问‘我的订单到哪了’‘发货没有’等场景。", "input_schema": { "type": "object", "properties": { "order_id": {"type": "string", "description": "用户的订单编号,格式为ORD开头"} }, "required": ["order_id"] }, "auth": {"type": "api_key", "scope": "order:read"}, "timeout_ms": 3000, "fallback": "human_handoff" }这里有两个关键点。第一,description必须写清楚“适合什么场景”,因为路由匹配主要靠它,写得越具体,命中率越高。比如“查询订单状态”和“查询某个订单的物流轨迹”这两个描述在语义上很接近,但实际是不同接口,描述里就要补充各自的典型问法,帮助路由区分。第二,fallback字段指定了这个能力失败时谁来接,可以指向另一个Agent节点,也可以指向人工交接节点。
注册表在内存里维护一个字典,支持运行时热更新。新加一个能力不用重启服务,直接调管理接口注册就行,这对业务频繁调整的场景太重要了。
2.2 语义路由:让请求准确触达最合适的能力节点
路由模块是整个Agent-Reach的心脏。用户请求进入系统后,先不急着丢给大模型做完整推理,而是先做路由决策。路由模块的职责是:从所有已注册的能力节点中,找出最应该处理当前请求的那一个。
早期版本我试过最简单的方法,用关键词匹配,比如请求里出现“订单”就路由到订单Agent。这种方案很快就废了,因为用户说话太随意:“我买的东西什么时候能送到”这句话里根本没有“订单”两个字,但意思就是查订单物流。后面改成了Embedding相似度匹配,把用户请求和每个能力节点的description同时向量化,算余弦相似度,取最高分且超过阈值的能力节点。
但只靠向量相似度也不够稳,我遇到过一个典型的坑:用户说“我要退掉这个商品”,退款的Agent description里有“退款”,售后的Agent description里也有“退款”,两个得分都高,路由就摇摆了。所以最终版我做成混合路由:
- 先用规则层做硬匹配(比如用户明确提到某个业务词,直接命中对应节点)
- 再用语义相似度做软匹配,取Top3候选
- 最后把候选节点列表交给一个小模型(比如GPT-4o-mini或本地的小参数模型)做最终选择,这一步成本很低但准确率提升非常明显
路由决策的完整链路是:规则硬匹配 → 向量召回Top3 → LLM终选。整个过程耗时在300毫秒以内,相比主对话动辄两三秒的LLM推理,完全可以接受。
2.3 执行追踪:Agent每走一步都在“留痕”
追踪模块是我坚持要做的,实际用下来也确实是排查问题的救命稻草。
执行追踪的数据结构很简单,每个节点执行完毕就追加一条事件记录:
{ "request_id": "req_8f3a2b1c", "trace_id": "trace_01", "hop": 1, "node": "order_query", "input_summary": "用户查询订单 ORD20240001 状态", "output_summary": "订单已发货,预计3天后送达", "latency_ms": 856, "status": "success", "timestamp": 1710000000 }这些事件统一写到Redis Stream里,用request_id做聚合。排查问题的时候,把某个请求的全部事件按hop排序,整个Agent的执行轨迹就还原出来了:先路由到了哪个节点、传了什么参数、返回了什么结果、耗时多少、哪一步失败了。业务方来质问“为什么给我这个答案”的时候,拿轨迹说话,比解释一万句“AI有时会犯错”都管用。
除了排查,轨迹数据还能用来做统计:每个能力节点的调用频次、平均耗时、失败率,这些数据反过来又能优化路由权重。比如某个节点总是超时,就可以在路由层把它降权。
2.4 兜底与熔断:触达失败也要“体面退场”
Agent跑在业务里,最怕的不是失败,是失败了用户还不知道。所以Agent-Reach强制执行兜底策略,任何一次调用必须要有一个结果。
兜底分三个等级。一级兜底是超时降级,比如订单查询接口正常3秒返回,超过10秒就认为异常,不再傻等,直接返回“查询超时,请稍后再试”并触发告警。二级兜底是自动重试,针对网络抖动这类临时性问题,用指数退避重试一次。这里必须强调幂等性设计——不是所有接口都适合重试,下单、扣款这类写操作盲目重试会出大事,所以注册中心里专门加了个idempotent字段,只有标记为幂等的读接口才允许自动重试。
三级兜底是人工交接,这是最重要的一层。当Agent经过路由和两轮尝试仍然无法完成任务时,不再硬编一个错误答案给用户,而是生成一个交接工单,把对话上下文、已执行的轨迹、失败原因打包转给人工坐席。这个设计让Agent-Reach的上线阻力小了很多——业务方最担心的就是Agent搞不定的时候乱答,有了人工交接兜底,风险就可控了。
3. 实现过程中的关键决策与踩坑记录
3.1 技术选型:FastAPI加Redis Stream,不为“大而全”买单
技术上我纠结过一阵。最早想直接基于LangChain或者AutoGen来搭Agent-Reach,但评估后放弃了。LangChain的抽象层级太多,版本升级经常破坏接口,出了问题你分不清是框架的Bug还是你自己的逻辑问题。AutoGen的多Agent对话模式太重,配置复杂,很难控住它的执行路径。
最后选型是:FastAPI做服务框架,Redis Stream做事件流和轻量消息队列,OpenAI或任意兼容接口的LLM做路由终选和主对话,向量库用轻量的Chroma或直接只用Embedding API算相似度,不在本地存向量。这套组合的好处是每个组件职责单一、替换成本低,出了问题能顺着调用链直接排查到具体代码。
Redis Stream是这方案里比较关键的一个选择。它天然支持按消费者组消费、消息持久化、时间范围查询,我直接拿它当执行追踪的存储,一个组件同时解决事件总线和数据持久化,不用再引入Kafka这种重家伙。对中低流量的内部系统来说,Redis Stream绰绰有余。
3.2 路由策略的演进:从“人工定规则”到“规则加语义”
路由模块我前后重构过三版。
第一版是纯规则路由,写了一大堆关键词映射。当时觉得业务术语就那么多,规则足够用。实际一跑就发现,自然语言的变体远超想象,同一个意思能有一百种问法,规则根本列不完,而且规则之间还会冲突。
第二版是纯向量路由,用Embedding模型把用户请求和所有能力描述向量化,取相似度最高的。问题出在阈值上,阈值设低了误命中率高,设高了呢,有些请求没有对应的能力节点,硬生生匹配到一个最接近但其实不对的节点上,反而更糟。
第三版就是我前面说的混合路由,规则层保证确定性,向量层保证泛化能力,LLM终选做精细化决策。三层互相兜底之后,路由准确率从最初的75%左右提升到了95%以上。如果你也要做类似系统,我建议直接从第三版起步,别在纯规则或纯向量上浪费时间了。
有个细节值得单独说:做LLM终选的时候,prompt里要把候选节点列表和各自的description给全,要求模型输出JSON格式的决策结果。早期我没严格约束输出格式,模型时不时在答案里夹一句废话,解析直接报错。后面用JSON Schema约束输出之后,解析就稳定了。
3.3 上下文管理:触达边界前先想好怎么收场
做Agent最容易被忽视的是上下文窗口消耗。用户跟Agent聊了二十轮,每一轮的对话历史都要塞进模型,工具调用的返回结果也要塞进去,很快上下文就逼近窗口边界了。
Agent-Reach的做法是分层管理上下文。短期的细节信息(比如刚查到的一个订单号)保留最近几轮,长期的关键信息(比如用户的核心诉求、已经确认的约束条件)做摘要提取,单独维护一个“关键信息区”。每次构造给模型的上下文时,不简单拼接全文,而是:系统提示词 + 关键信息摘要 + 最近N轮对话 + 本次路由结果。
这套设计让单次对话能支撑的轮数从十几轮提升到了一百多轮,用户体验好非常多。而且摘要提取的调用用的是小模型,成本很低,不会成为瓶颈。踩过的坑是:摘要不能每次都重新生成,要在前一轮摘要基础上增量更新,否则摘要本身的Token消耗会抵消省下来的部分。
3.4 并发与限流:别让Agent被一个突发流量打垮
上线前我以为最大的风险是模型答错,上线后才发现最现实的风险是并发上来后各种超时。某个运营活动一推,用户咨询量瞬间冲到平时的3倍,Agent服务的LLM调用、API调用全都开始排队,超时错误跟着出现。
后来我在Agent-Reach入口层加了令牌桶限流,控制每秒进入Agent编排层的请求数。超过上限的请求不是直接丢弃,而是返回“当前咨询量较大,请稍后重试”并引导用户留言。另外每个能力节点都有独立的信号量控制最大并发数,避免某个慢接口占满线程池拖垮整个服务。这套保护机制上线后,再没出现过全局雪崩。
节点级别的并发控制我用了asyncio.Semaphore,每个能力节点初始化时注册一个独立信号量,超时等待时间超过2秒就直接拒绝并走降级兜底,不再无限排队。同时不同能力节点之间按优先级隔离,订单查询这种核心能力分配更多并发额度,日志查询这类非关键能力让出资源。
4. 实操复现:从零搭一个最小可用的Agent-Reach
4.1 环境准备与基础框架搭建
下面这套步骤可以让你在本机跑通一个最简版本的Agent-Reach,核心验证的是“路由 + 执行 + 追踪”这条链路。环境要求很简单:Python 3.10以上、装了Redis、配好任意OpenAI兼容的API Key。
pip install fastapi uvicorn redis openai pydantic先写一个最小服务入口,把FastAPI应用和Redis客户端初始化好:
# main.py from fastapi import FastAPI import redis app = FastAPI(title="Agent-Reach") r = redis.Redis(host="localhost", port=6379, decode_responses=True) @app.get("/health") def health(): return {"status": "ok"}注意:Redis Stream的消费者组功能需要Redis 5.0以上版本,建议直接装最新稳定版,避免老版本特性缺失。
4.2 实现能力注册中心
注册中心核心是一个内存字典加一个注册接口。每个能力节点注册后,会同时被用于路由匹配和工具调用两个阶段:
# registry.py class AbilityRegistry: def __init__(self): self._abilities = {} def register(self, ability: dict): self._abilities[ability["name"]] = ability return {"status": "registered", "name": ability["name"]} def list_abilities(self): return list(self._abilities.values()) def get(self, name: str): return self._abilities.get(name) registry = AbilityRegistry()在FastAPI里暴露一个管理接口:
@app.post("/abilities/register") def register_ability(ability: dict): return registry.register(ability) @app.get("/abilities") def list_abilities(): return registry.list_abilities()注册两个测试用能力节点:一个是订单查询,一个是物流查询,故意把描述写得语义接近,方便后面看路由怎么区分。
4.3 实现语义路由
路由模块把用户请求先做向量化,跟所有能力描述算余弦相似度,拿到Top3候选后再交给LLM终选。为了不依赖外部向量数据库,这里直接调用Embedding API:
# router.py import numpy as np from openai import OpenAI client = OpenAI() def get_embedding(text: str): resp = client.embeddings.create( model="text-embedding-3-small", input=text ) return resp.data[0].embedding def cosine_sim(vec_a, vec_b): return float(np.dot(vec_a, vec_b) / (np.linalg.norm(vec_a) * np.linalg.norm(vec_b))) def semantic_route(user_input: str, abilities: list, top_k: int = 3): input_vec = get_embedding(user_input) scored = [] for ability in abilities: desc_vec = get_embedding(ability["description"]) score = cosine_sim(input_vec, desc_vec) scored.append({"ability": ability, "score": score}) scored.sort(key=lambda x: x["score"], reverse=True) return scored[:top_k]注意这里有个效率问题,每个能力描述每次都重新算Embedding很浪费。实际应该在能力注册时就算好并缓存,用户请求来了只算一次输入向量,然后跟缓存的向量做余弦相似度。我在代码里省略了缓存部分,是为了让逻辑更清晰,你自己做的时候务必加上。
路由终选的LLM调用:
ROUTER_PROMPT = """ 你是路由器。根据用户请求,从候选能力中选择最合适的一个。 候选能力: {abilities} 用户请求:{user_input} 只输出JSON格式,例如:{{"choice": "order_query", "confidence": "high"}} """ def llm_final_route(user_input: str, candidates: list): abilities_text = "\n".join( [f"- {a['ability']['name']}: {a['ability']['description']}" for a in candidates] ) prompt = ROUTER_PROMPT.format(abilities=abilities_text, user_input=user_input) resp = client.chat.completions.create( model="gpt-4o-mini", messages=[{"role": "user", "content": prompt}], response_format={"type": "json_object"} ) result = resp.choices[0].message.content return json.loads(result)["choice"]4.4 实现执行与追踪
最后写编排执行层。拿到路由结果后,执行对应能力节点,并把关键事件写入Redis Stream:
# executor.py import json, time, uuid from registry import registry import redis r = redis.Redis(host="localhost", port=6379, decode_responses=True) def execute_ability(name: str, params: dict, request_id: str, hop: int): ability = registry.get(name) if not ability: return {"status": "error", "message": "ability not found"} start = time.time() # 这里是模拟执行,实际应该调用你的业务API mock_result = f"根据订单 {params.get('order_id')} 查询到:已发货,预计3天后送达" latency_ms = int((time.time() - start) * 1000) event = { "request_id": request_id, "hop": hop, "node": name, "input_summary": f"查询订单 {params.get('order_id')}", "output_summary": mock_result, "latency_ms": latency_ms, "status": "success", "timestamp": int(time.time()) } r.xadd("trace_stream", event) return {"status": "success", "result": mock_result} def run_agent(user_input: str): request_id = "req_" + uuid.uuid4().hex[:8] candidates = semantic_route(user_input, registry.list_abilities()) chosen = llm_final_route(user_input, candidates) params = {"order_id": "ORD20240001"} hop = 1 return execute_ability(chosen, params, request_id, hop)4.5 端到端测试
启动服务后,用curl验证整条链路:
curl -X POST http://localhost:8000/abilities/register \ -H "Content-Type: application/json" \ -d '{"name": "order_query", "description": "查询订单状态和发货进度,适合问我的订单到哪里了", "input_schema": {"type": "object", "properties": {"order_id": {"type": "string"}}}}' curl -X POST http://localhost:8000/abilities/register \ -H "Content-Type: application/json" \ -d '{"name": "logistics_query", "description": "查询物流轨迹和快递位置,适合问包裹走到哪了", "input_schema": {"type": "object", "properties": {"order_id": {"type": "string"}}}}' curl -X POST http://localhost:8000/run \ -H "Content-Type: application/json" \ -d '{"input": "我的东西发货了吗"}'第一条请求“我的东西发货了吗”语义上既可能命中订单查询也可能命中物流查询,但因为是问“发货没”,路由应当把它分给order_query。你可以把请求换成“快递到哪了”再试,路由会偏到logistics_query。这就是语义路由和规则匹配最本质的区别:它理解的是意图而不是字面词。
执行完去Redis里查trace_stream,能看到完整的事件记录,这就是后面所有排查和复盘的数据基础。
5. 常见问题与排查技巧实录
5.1 路由命中率低,总选错能力节点
遇到过几次选错节点的情况,每次排查发现绝大多数不是模型的问题,而是能力描述写得不好。描述里写“查询订单信息”,还不如写“查询订单状态和发货进度,适合用户询问我的订单到哪里了、发货没有等场景”。描述要面向用户会怎么问,而不是面向这个接口能干什么。
另一个高频原因是新增能力和已有能力之间的区分度不够。比如退款Agent和售后Agent本质上强相关,但你要让路由能区分,就得在描述里各写清楚自己的独占场景,退款那边强调“资金退回”,售后那边强调“问题处理与换货”。如果描述的语义交叠太多,路由拿到的向量相似度差异会很小,终选模型也容易犹豫。
改完描述后要做一个简单的回归测试:把历史用户问题整理成测试集,跑一遍路由,统计每个问题是否正确触达目标节点。我维护了一个两百多条问题的回归集,每次改完描述或路由逻辑就跑一遍,这个习惯帮我挡住了不少隐性回归问题。
5.2 工具调用超时、重试反而加重故障
刚开始给所有能力节点都加了“失败重试2次”的逻辑,结果有一次下游系统确实故障了,重试请求把它的入口彻底打挂,故障时间反而延长了。排查后把重试策略改成:只对幂等的读接口重试,并且遵循指数退避,第一次失败等500毫秒重试,第二次失败等1秒。每次重试前检查Redis里该request_id是否已经执行过这个节点,避免上游超时但实际已成功的情况下重复调用。
这个幂等检查很重要。比如说你调了一个创建工单的API,第一次调用其实成功了,但响应回来超时了,如果无脑重试,工单就会建两份。我用的方案是在执行前先检查Redis中是否存在该request_id + node的成功标记,有就直接返回上次的结果,没有才执行。这是一个非常克制但有效的保护。
5.3 追踪日志乱序、查不到完整链路
Redis Stream写事件天然有先后顺序,但多节点并发执行时,不同节点的完成时间不同,如果不加hop序号只看timestamp,排序会乱。我后来在每个事件里加了hop字段,用索引值强制表达执行顺序,用timestamp做参考。查询时先按request_id过滤,再按hop排序,链路就规整了。
还有一种情况是追踪事件缺失,排查发现是执行过程中有一步抛了异常,但异常没有写日志就终止了。解法是在执行函数的finally块里强制写一条状态为failed的事件,确保任何一个请求都有最终记录。再配合前面说的人工交接兜底,用户侧看到的是一个体面的失败提示,系统侧看到的是完整的事件链,两边都安心。
5.4 Token消耗比预想高,成本失控
Agent服务跑起来后,成本大头主要有两块。一是主对话模型的上下文越来越长,二是路由阶段和小模型摘要阶段累积的调用量。优化方案是把路由终选模型换成最便宜的小模型,实测对路由准确率几乎没有影响,但成本降了一个量级。主对话侧用上下文压缩策略,把历史消息里已无价值的寒暄内容丢弃,只保留关键信息摘要。
另外建议给每个会话设一个Token使用上限,达到上限后自动触发“总结当前内容并提示用户开始新话题”的策略。用户不会有感知,成本却能控制住。我自己跑下来的数据是,这套优化后单会话平均成本下降了60%以上。
5.5 容易被忽略的权限与审计问题
Agent调用业务API时,权限控制不能漏。我踩过的一个坑是,Agent拿了当前用户的身份去调接口,但没校验这个用户是否有权限执行该操作,结果User A让Agent查到了User B的订单信息,因为订单API只校验了API Key没校验用户级权限。后来Agent-Reach在能力执行层强制注入当前用户上下文,并在调用业务API前做一次能力级和资源级的双重校验。
审计日志这块也不要嫌麻烦。每一次Agent触达业务API的原始请求和响应,建议连同用户身份、时间戳一起落库保存。出了问题或者业务方有争议的时候,这是唯一的客观证据。我遇到过一次用户投诉说Agent承诺了退款但实际没退,靠审计日志查出来是那个订单本身就不满足退款条件,Agent的承诺是模型幻觉,这个日志直接帮我们定位到了问题根源。
最后分享两个实际使用中的体会
Agent-Reach这套东西做完并跑了一段时间后,我最大的感触是:做Agent工程化,真正难的不是让模型变聪明,而是让系统变得可控。模型幻觉、工具失败、网络超时这些都是常态,你能做的是设计好触达的路径、兜底的策略、审计的依据,让Agent在不确定性中依然给出确定性的行为。
另一个体会是路由层的价值远超我最初的预期。我原以为它只是“选个工具”,实际上它是整个系统的门面。路由选对了,后面的执行自然顺畅;路由选错了,模型再聪明也白搭。所以我很建议你把路由模块单独拎出来设计和优化,它值得配得上你最多的注意力。
最后再分享一个小技巧:给每个能力节点写描述的时候,用真实的历史用户问题去反推描述,而不是自己想象用户会怎么问。我改完描述后,路由准确率从80%直接跳到95%。这个习惯到现在我都还在用。