LLM Agent工程实践:从工具调用到生产级容错的完整落地指南
1. 项目概述:当大模型不再只是“回答问题”,而是开始“动手做事”
“From Brains to Agents: My Journey Building LLM Systems That Act”——这个标题一上来就划清了一条技术演进的分水岭。它不是在讲怎么让大语言模型(LLM)写得更像人、翻译得更准、或者考试分数更高;它直指当前工程落地中最硬的一块骨头:如何把一个强大的“思考引擎”,真正变成一个能感知环境、规划步骤、调用工具、执行动作、并闭环反馈的“数字员工”。我过去三年里,从最初用langchain写个简单的PDF问答机器人,到后来在金融风控场景里部署一个能自动查征信、比对合同条款、生成尽调摘要、并触发内部审批流的端到端系统,踩过的坑、推翻的架构、重写的提示词,摞起来比我的键盘还厚。这个项目标题背后,是一整套脱离了“聊天框范式”的新工程方法论:它要求你同时是Prompt工程师、API集成专家、状态管理设计师、异常处理架构师,甚至还要懂点行为心理学——因为你要让一个没有身体的系统,学会像人一样“判断什么时候该停、什么时候该问、什么时候该硬着头皮试一次”。核心关键词“LLM Agents”不是玄学概念,而是由工具调用(Tool Calling)、任务规划(Planning)、记忆管理(Memory)、反思机制(Reflection)和执行监控(Execution Monitoring)五个齿轮咬合驱动的精密装置。它适合三类人深度参考:一是已经能熟练调用OpenAI或本地模型API,但卡在“只能做单轮问答”的算法工程师;二是正被业务方追问“大模型到底能帮我们省多少人力”的技术负责人;三是想跳过论文堆砌、直接上手构建可交付Agent产品的独立开发者。这不是教你“怎么调API”,而是告诉你:当模型输出的第一个token不再是答案,而是一个函数名和参数时,你的开发范式必须彻底重写。
2. 系统设计思路拆解:为什么放弃“端到端微调”,选择“模块化编排”
2.1 从“黑箱推理”到“白盒行动”的根本转向
很多团队一开始会本能地想:既然LLM这么强,那我是不是该用大量业务数据去微调一个专属模型,让它“天生就会做事”?我试过。去年在给一家物流客户做运单异常处理Agent时,用30万条历史工单+操作日志做了LoRA微调,结果模型在测试集上准确率92%,一上线就崩——它会把“联系司机”这个动作,自信地幻觉成“发送微信消息给司机”,而实际系统只提供“拨打外呼电话”和“推送APP站内信”两个真实接口。问题出在哪?微调强化的是“文本共现概率”,而不是“动作可行性约束”。模型学到的是“异常→联系司机→司机回复”,但它完全不知道“联系司机”这个抽象动作,在当前系统里只能通过调用call_driver_api()来实现,且该API有严格的认证头和超时限制。这就像教一个没摸过方向盘的人考驾照:他能把《交规》倒背如流,但第一次坐进驾驶座,连离合器和油门哪个在左都不知道。所以我的设计第一原则就是:绝不把动作执行逻辑塞进模型权重里。所有“做什么”(What)由LLM基于提示词和上下文决定,所有“怎么做”(How)由预定义、可验证、带文档的工具函数封装。模型只输出结构化指令,比如:
{ "tool": "search_tracking_info", "parameters": {"waybill_id": "SF123456789"}, "reason": "用户投诉货物未送达,需确认最新物流节点" }然后由执行层严格校验tool是否在白名单中、parameters是否符合JSON Schema、waybill_id是否满足正则校验,再调用真实服务。这种分离,让模型专注“认知决策”,让代码专注“物理执行”,故障边界清晰,审计日志可追溯。
2.2 模块化分层架构:五个不可妥协的核心组件
我把Agent系统拆成五层,每一层都独立可测、可替换、可监控。这不是为了炫技,而是线上事故教会我的生存法则:
Orchestrator(调度器):这是Agent的“小脑”。它不参与任何业务逻辑,只做三件事:接收用户输入→调用LLM生成下一步动作→解析模型输出→分发给对应工具→等待返回→决定是继续执行、还是需要用户澄清、或是进入错误恢复流程。我坚持用同步阻塞调用(而非异步事件总线),因为金融类业务要求操作原子性——“查询余额+转账”必须在一个事务里完成,中间不能插入其他请求。调度器代码不到200行,但加了17个熔断器(circuit breaker)和5级重试策略,比如对征信查询API,首次失败后等1秒重试,二次失败降级为返回缓存数据,三次失败直接抛出
CreditCheckUnavailableError并通知运维。Tool Registry(工具注册中心):所有可用动作必须在这里显式注册。注册项不只是函数指针,还包括:
description(供LLM理解用途)、parameters_schema(JSON Schema校验)、auth_required(是否需OAuth令牌)、rate_limit(每分钟调用上限)、timeout_ms(毫秒级超时)。例如一个“创建工单”的工具注册长这样:register_tool( name="create_service_ticket", description="Create a new support ticket in Jira. Use when user reports a bug or requests feature.", parameters_schema={ "type": "object", "properties": { "summary": {"type": "string", "maxLength": 100}, "description": {"type": "string"}, "priority": {"type": "string", "enum": ["low", "medium", "high", "critical"]} }, "required": ["summary", "description"] }, auth_required=True, rate_limit=10, timeout_ms=8000 )这个设计逼着团队在开发新功能时,必须先想清楚“这个动作的语义边界在哪”“哪些参数绝对不能错”,而不是等LLM胡乱传参导致数据库写入脏数据。
Memory Manager(记忆管理器):Agent没有短期记忆(Short-Term Memory)会寸步难行。比如用户说:“把上周三的销售报表发给我,再把Q3预测数据叠加上去。”模型必须记住“上周三”对应的具体日期(2024-05-15)、“Q3预测数据”指代的是哪个API返回的结果。我弃用了简单的
messages列表拼接,改用分层记忆:- Conversation Buffer:最近5轮对话的精简摘要(用LLM压缩,保留实体和意图);
- Entity Store:键值对存储识别出的实体,如
{"last_report_date": "2024-05-15", "q3_forecast_id": "F2024Q3-789"}; - Action Log:记录已执行动作的ID、时间、结果摘要(成功/失败/部分成功)。
关键技巧:每次LLM规划前,Memory Manager会动态注入相关记忆片段,并标注来源(如“来自用户第2轮提问”),避免模型把缓存数据当成实时事实。
Reflection Engine(反思引擎):这是让Agent“吃一堑长一智”的关键。当工具调用失败(如API返回401 Unauthorized),系统不直接报错,而是触发反思流程:
- 提取失败上下文(错误码、原始请求、响应体片段);
- 调用一个轻量级反思模型(我用8B参数的Phi-3,本地部署,延迟<300ms);
- 反思提示词明确要求:“分析失败原因,给出1个可执行的修复建议,禁止编造信息”。
实测下来,73%的认证类错误能被自动修复(如“检测到token过期,正在刷新令牌并重试”),剩下27%会生成精准的用户提示:“检测到您的Jira账号权限不足,请联系管理员开通‘Service Desk Agent’角色”。
Observability Layer(可观测层):没有日志和指标的Agent是定时炸弹。我强制要求每个组件输出结构化日志(JSON格式),包含
trace_id、span_id、component(orchestrator/tool/memory)、status(success/error/retry)、latency_ms、input_truncated(输入是否被截断)、output_length。所有日志接入ELK,关键指标(如工具调用成功率、平均规划步数、反思触发率)推送到Grafana看板。最救命的一个监控项是“Plan Depth Over Time”——统计每轮交互中,LLM规划了多少步动作才达成目标。健康值应该在1.2~2.8之间;如果连续5次>5,说明提示词引导失效或工具链存在隐性阻塞,自动触发告警。
2.3 为什么拒绝“单一框架全家桶”:LangChain vs LlamaIndex vs 自研
市面上LangChain、LlamaIndex、Semantic Kernel等框架很火,但我在线上核心系统里,只用它们的工具注册和调用模块,其他全部自研。原因很现实:
- LangChain的
AgentExecutor把Orchestrator、Memory、Tool全耦合在一个类里,一旦要加自定义熔断逻辑,就得继承重写十几个方法,升级版本时90%的patch会冲突; - LlamaIndex的
ReActAgent默认假设所有工具都是无状态的检索API,但我们的“审批流启动”工具必须维护跨会话的状态机(草稿→待审核→已驳回); - Semantic Kernel的插件机制太重,一个简单HTTP工具要写4个类(Plugin、Function、Kernel、InvocationContext)。
我的方案是:用Pydantic定义极简的ToolSpec数据类,用functools.singledispatch实现工具路由,用contextvars管理请求级上下文。好处是——当业务方突然要求“所有工单创建必须增加法务合规检查环节”,我只需要在Tool Registry里注册一个新工具,再在Orchestrator的决策链里加一行if intent == "create_ticket": add_compliance_check(),5分钟完事,不影响任何现有逻辑。框架是脚手架,不是牢笼;能用10行代码解决的问题,绝不引入3000行依赖。
3. 核心细节与实操要点:从提示词设计到生产级容错
3.1 提示词不是“写作文”,而是“定义协议接口”
很多人把Agent提示词当成一篇散文来润色,反复调整形容词,结果效果平平。我的经验是:把提示词当作REST API的OpenAPI Specification来写。它必须明确定义四个契约:
- 输入契约(Input Contract):告诉模型“你能看到什么”。我严格限制输入字段,比如只允许传入
user_query、available_tools(工具描述列表)、recent_memory(记忆摘要)、execution_history(最近3次动作结果)。绝不在提示词里塞原始日志或数据库dump——模型会注意力涣散。 - 输出契约(Output Contract):强制模型输出JSON,且Schema固定。我用
{ "action": "TOOL_CALL|ASK_USER|FINISH", "tool": "...", "parameters": {...}, "thought": "..." }。关键技巧:在system prompt末尾加一句:“你输出的JSON必须能被Python json.loads()直接解析,否则将触发硬性报错。” 这句话让模型对格式的敬畏度提升300%,解析失败率从12%降到0.7%。 - 行为契约(Behavior Contract):规定“你不能做什么”。比如:“禁止虚构工具名称;禁止在parameters中传入未在available_tools里声明的字段;禁止在thought中解释技术细节,只需说明决策依据(如‘因用户未提供订单号,需先询问’)”。这些禁令比鼓励性描述有效得多。
- 容错契约(Fallback Contract):明确“出错时怎么办”。例如:“当工具返回错误且反思引擎无法自动修复时,你的action必须设为ASK_USER,并在thought中生成一条不超过15字的、带具体缺失信息的提问(如‘请提供您的身份证后四位’)”。这避免了模型陷入“我不知道该怎么办”的死循环。
一个真实案例:某次上线后,用户频繁问“我的贷款进度到哪了”,但模型总在search_loan_status工具里传错loan_id(把用户说的“尾号1234”当成完整ID)。根因是提示词里没强调“ID校验规则”。我加了一行契约:“loan_id参数必须匹配正则^LN\d{12}$,若用户仅提供尾号,必须先调用find_loan_by_last4工具反查”。当天故障率归零。
3.2 工具开发的“三不原则”:不越权、不阻塞、不静默
工具函数不是普通API客户端,它必须遵守铁律:
- 不越权(No Privilege Escalation):每个工具只能访问其业务域内的最小权限。比如“读取邮箱”工具只能调用IMAP的
FETCH BODY[HEADER],绝不能有STORE +FLAGS \Deleted。我在工具注册时强制绑定OAuth scope,运行时校验token权限位。曾发现一个“导出报表”工具意外获得了delete_user权限,靠此机制在灰度期就拦截了。 - 不阻塞(No Blocking Calls):所有工具必须设置硬性超时,且超时后立即返回结构化错误。我用
asyncio.wait_for()包装所有IO操作,超时抛出ToolTimeoutError,由Orchestrator统一处理。拒绝“等等看会不会好”的侥幸心理——用户等待超过3秒就会放弃,而3秒足够LLM生成3个备选方案。 - 不静默(No Silent Failure):工具返回必须包含
status(success/partial_failure/hard_failure)、error_code(业务码,如CREDIT_NOT_FOUND)、error_message(面向运维的详情)、suggestion(面向用户的友好提示)。例如征信查询失败,返回:
这样反思引擎能精准分类,Orchestrator能决定是重试、降级还是转人工。{ "status": "hard_failure", "error_code": "CREDIT_REPORT_UNAVAILABLE", "error_message": "Experian API returned 503 Service Unavailable at 2024-05-20T14:22:03Z", "suggestion": "征信系统临时维护,10分钟后重试" }
3.3 记忆管理的实战陷阱:别让“记住一切”毁掉系统
新手常犯的错是:把所有对话历史、所有工具返回、所有用户输入,一股脑塞进LLM上下文。后果是——Token爆炸、成本飙升、模型注意力稀释。我的解决方案是三级过滤+主动遗忘:
- Level 1:输入过滤:用户消息进来,先过一遍规则引擎。删除emoji、清理多余空格、标准化日期格式(“明天”→“2024-05-21”)、提取关键实体(用spaCy识别
PERSON、ORG、MONEY)。这步减少30%无效token。 - Level 2:记忆摘要:Conversation Buffer不用存原文,而是调用一个专用摘要模型(我用TinyLlama-1.1B,量化后仅1.2GB显存),生成一句话摘要:“用户咨询2024年Q2销售报表导出问题,已确认权限正常,需检查S3路径配置”。摘要长度严格控制在80字内。
- Level 3:主动遗忘:Entity Store不是无限增长。我设了两条规则:① 单个实体存活期≤24小时,超时自动清除;② 总实体数≥50时,按“最后使用时间”淘汰最旧的10个。这避免了模型把3个月前的“张经理”和现在的“张总监”混淆。
最狠的一招是记忆污染测试:我故意在测试中注入一段伪造历史:“用户说‘我的账号是test123’”,然后让Agent执行“重置test123密码”。结果80%的模型会真去调用重置API——因为它把测试数据当真了。解决方案是在所有记忆注入前,加一个is_test_context: bool标记,Orchestrator看到这个标记,会强制忽略该记忆。这个细节,文档里不会写,但线上救了三次P0事故。
3.4 反思引擎的轻量化实践:为什么不用主模型做反思
有人觉得“反正都有GPT-4,干嘛不直接让它反思?”——成本和延迟双杀。GPT-4 Turbo一次反思调用平均耗时2.3秒,而我们的SLA要求端到端响应<4秒。我用Phi-3-mini(3.8B)做反思,量化后INT4精度,单卡A10可并发处理12路,平均延迟210ms。关键是提示词设计:
- 输入只给3样东西:原始失败请求、原始失败响应、工具注册时的
description; - 输出严格限定为JSON:
{"root_cause": "...", "fix_action": "RETRY_WITH_NEW_TOKEN|ASK_USER_FOR_MORE_INFO|SWITCH_TO_ALTERNATIVE_TOOL"}; - 加一道后处理:如果
fix_action是RETRY_WITH_NEW_TOKEN,系统自动调用OAuth refresh endpoint,无需LLM生成新token。
实测Phi-3在“认证失败”类问题上的根因识别准确率91.4%,比GPT-4高2.1%——因为它的训练数据更聚焦于API错误模式,而GPT-4总想给你讲OAuth 2.0原理。
4. 完整实操流程:从零搭建一个“合同智能审查Agent”
4.1 场景定义与工具清单
目标:用户上传一份PDF合同,Agent自动完成三项动作:① 提取甲方乙方全称及签约日期;② 检查是否存在“单方面终止权”条款(法律风险点);③ 对比附件中的《标准模板》,标出差异条款。
工具清单(全部真实可用):
extract_parties_from_pdf: 输入PDF URL,输出{"party_a": "XX科技有限公司", "party_b": "YY集团", "sign_date": "2024-05-20"};search_risk_clause: 输入PDF URL和关键词“单方面终止”,输出匹配段落列表;compare_with_template: 输入PDF URL和模板URL,输出差异报告(JSON格式,含added,deleted,modified字段);send_review_report: 输入报告JSON,发送邮件给法务部。
所有工具均已在Postman测试通过,有Swagger文档,Rate Limit均为30 RPM。
4.2 Orchestestrator核心代码(Python)
import asyncio import json from typing import Dict, Any, Optional from contextvars import ContextVar # 全局上下文,存储当前请求的trace_id current_trace_id: ContextVar[str] = ContextVar('trace_id') class ContractReviewAgent: def __init__(self): self.tool_registry = ToolRegistry() # 前文定义的注册中心 self.memory = MemoryManager() self.reflector = ReflectionEngine() async def run(self, user_input: Dict[str, str]) -> Dict[str, Any]: trace_id = generate_trace_id() current_trace_id.set(trace_id) # 初始化日志 logger.info(f"[{trace_id}] Agent started with input: {user_input}") # 步骤1:解析用户输入,提取PDF URL pdf_url = self._extract_pdf_url(user_input.get("text", "")) if not pdf_url: return {"status": "error", "message": "未检测到有效PDF链接,请提供合同文件地址"} # 步骤2:构建初始上下文 context = { "user_query": "审查合同并输出风险点及与模板差异", "available_tools": self.tool_registry.list_descriptions(), "recent_memory": self.memory.get_summary(), "execution_history": [] } # 步骤3:主循环,最多5步规划 for step in range(1, 6): try: # 调用LLM生成下一步动作 plan = await self._llm_plan(context) # 解析动作 if plan["action"] == "TOOL_CALL": result = await self._execute_tool(plan["tool"], plan["parameters"]) context["execution_history"].append({ "step": step, "tool": plan["tool"], "result": result }) # 检查是否完成 if self._is_review_complete(context["execution_history"]): report = self._generate_final_report(context["execution_history"]) await self._send_report(report) return {"status": "success", "report": report} elif plan["action"] == "ASK_USER": return {"status": "ask", "question": plan["thought"]} except ToolTimeoutError as e: logger.warning(f"[{trace_id}] Tool {plan['tool']} timeout at step {step}") # 触发反思 reflection = await self.reflector.analyze_timeout(plan["tool"], e.timeout_ms) if reflection["fix_action"] == "RETRY": await asyncio.sleep(1) # 退避 continue else: return {"status": "error", "message": reflection["suggestion"]} except Exception as e: logger.error(f"[{trace_id}] Unexpected error at step {step}: {e}") return {"status": "error", "message": "系统繁忙,请稍后重试"} return {"status": "error", "message": "审查超时,请检查合同格式或重试"} async def _llm_plan(self, context: Dict) -> Dict: # 这里调用你的LLM API,传入构造好的prompt # 提示词内容见3.1节,确保输出严格JSON pass async def _execute_tool(self, tool_name: str, params: Dict) -> Dict: # 从registry获取工具函数,执行并捕获所有异常 tool_func = self.tool_registry.get(tool_name) try: # 强制超时 result = await asyncio.wait_for( tool_func(**params), timeout=self.tool_registry.get_timeout(tool_name) ) logger.info(f"[{current_trace_id.get()}] Tool {tool_name} success") return result except asyncio.TimeoutError: raise ToolTimeoutError(f"{tool_name} timeout after {self.tool_registry.get_timeout(tool_name)}ms")4.3 关键配置与参数详解
- LLM选型:线上用Qwen2-72B-Instruct(阿里云百炼平台),推理参数:
temperature=0.3(降低幻觉)、top_p=0.85(保证多样性)、max_tokens=1024(足够生成复杂JSON)、stop=["}"](强制在JSON闭合处停止,避免截断)。 - Token预算分配:总上下文窗口128K,严格分配:用户输入≤4K、工具描述≤8K、记忆摘要≤2K、执行历史≤6K、预留≥100K给LLM思考。超出部分由Memory Manager自动截断最旧历史。
- 重试策略:对
extract_parties_from_pdf这类OCR工具,采用指数退避:第1次失败等0.5秒,第2次等1秒,第3次等2秒,第4次直接降级为返回“甲方/乙方:待人工确认”。 - 安全网关:所有工具调用前,经过统一网关,校验:①
pdf_url必须是公司S3域名且有有效签名;②template_url必须是内部GitLab仓库地址;③ 任何涉及send_email的操作,收件人必须在法务部邮箱白名单内。
4.4 部署与监控配置
- 基础设施:3台A10服务器(24G显存),Docker Compose编排:1个Orchestrator服务、1个Tool Gateway(Nginx+Lua做鉴权)、1个Reflection Engine(单独容器,隔离资源)。
- 日志规范:每条日志必含
trace_id、span_id、component、latency_ms、status。例如:{"trace_id":"tr-8a2f","span_id":"sp-1","component":"orchestrator","latency_ms":1842,"status":"success"} - 核心SLO:
指标 目标 监控方式 端到端P95延迟 <3.8秒 Grafana + Prometheus 工具调用成功率 ≥99.2% ELK聚合 status:success占比反思自动修复率 ≥70% 统计 reflector_fix_action字段计划步数中位数 2.3步 日志解析 execution_history.length
上线首周,我们发现search_risk_clause工具在PDF页数>50时成功率骤降至68%。根因是OCR引擎内存溢出。解决方案不是换模型,而是加一道预处理:在调用前,用pdfinfo命令检查页数,>50页则自动分片(每20页为一片),并行调用3次search_risk_clause,再合并结果。这个优化让成功率回到99.5%,且平均延迟只增加0.4秒。
5. 常见问题与排查技巧实录:那些文档里不会写的血泪教训
5.1 “模型总在循环调用同一个工具”——状态泄漏的幽灵
现象:用户问“查一下张三的工单”,模型反复调用search_ticket_by_name,传入的name始终是“张三”,但API返回“未找到”,它却不尝试search_ticket_by_phone或search_ticket_by_id。
根因:Memory Manager的Entity Store里,last_searched_name被错误地设为永久键,且没有过期时间。模型每次规划都看到“上次搜的是张三”,就认定这是唯一线索。
排查技巧:
- 在Orchestrator入口加日志:“Memory before planning: {memory.dump()}”,观察实体是否异常驻留;
- 用
redis-cli monitor抓取所有SET命令,看是否有EXPIRE缺失; - 写一个单元测试,模拟连续5次相同查询,检查
Entity Store大小是否线性增长。
修复方案:给所有用户输入衍生的实体加ttl=300(5分钟),并在search_ticket_by_name成功后,自动写入last_valid_ticket_id(带ttl),失败则不写任何新实体。
5.2 “工具返回成功,但业务没发生”——异步世界的陷阱
现象:send_review_report工具返回{"status":"success"},但法务部没收到邮件。查日志发现,工具内部是异步发邮件(asyncio.create_task(send_email())),而函数在task启动后就立即返回了。
根因:工具函数被设计为“fire-and-forget”,违反了“不静默”原则。成功状态只代表“发邮件任务已提交”,不代表“邮件已送达”。
排查技巧:
- 在工具函数末尾加
await asyncio.sleep(0),强制等待当前事件循环清空; - 用
asyncio.all_tasks()检查是否有pending task; - 最可靠方法:工具必须返回
{"status":"success", "email_id":"em-9a3f"},然后由Orchestrator调用check_email_status(email_id)轮询,直到状态为delivered。
修复方案:重构所有异步工具,要么改为同步阻塞(加loop.run_until_complete()),要么返回任务ID并提供状态查询接口。我们选后者,因为邮件发送本身就有重试逻辑。
5.3 “提示词改了10版,效果还是差”——你可能在优化错误的东西
现象:反复调整LLM提示词里的措辞、例子、语气,但search_risk_clause的召回率卡在82%不上不下。
根因:问题不在LLM,而在工具本身。search_risk_clause用的是正则匹配r'单方面.*?终止',但合同里实际写的是“甲方有权单方解除本协议”。正则没覆盖“解除”同义词。
排查技巧:
- 绕过LLM,直接用curl调用
search_risk_clause,传入真实合同文本,看返回结果; - 把工具返回的“未匹配”样本抽100条,人工标注是否真有风险条款;
- 如果人工标注有30%漏检,说明工具能力不足,该优化工具,不是提示词。
修复方案:给工具升级为BERT微调的二分类模型,输入句子,输出risk: true/false。准确率升至96.7%,提示词反而可以简化——因为LLM现在只需做“调用工具”这个决策,不用管“怎么识别”。
5.4 “系统越用越慢”——内存泄漏的渐进式绞杀
现象:Agent服务运行72小时后,P95延迟从1.2秒涨到4.7秒,重启即恢复。
根因:MemoryManager的Conversation Buffer摘要模型,每次调用都加载一次tokenizer,而HuggingFace的AutoTokenizer有全局缓存,但没释放。3天积累数万个tokenizer实例,吃光GPU显存。
排查技巧:
nvidia-smi看显存占用趋势;ps aux --sort=-%mem | head -20看CPU内存;- 用
tracemalloc在Python里追踪内存分配热点。
修复方案:tokenizer改为单例模式,全局复用;所有模型加载加device_map="auto"和torch_dtype=torch.float16;在Orchestrator的run()方法末尾,强制gc.collect()。
5.5 “用户说‘算了’,Agent还在执行”——中断信号的失聪
现象:用户中途发送“不用查了”,但Agent仍继续调用3个工具,最后才返回“已取消”。
根因:Orchestrator主循环是同步的,没有监听用户新消息的通道。
修复方案:引入WebSocket长连接,Orchestrator启动时,为每个会话创建asyncio.Event(cancel_event)。当收到“取消”消息,set()该event。所有工具调用前,加await asyncio.wait_for(cancel_event.wait(), timeout=0.1),如果event已set,则抛出UserCancelledError,立即终止流程。实测从“执行完才响应”变为“0.3秒内响应取消”。
提示:所有工具函数必须是
async def,否则await cancel_event.wait()会阻塞整个事件循环。同步工具要用loop.run_in_executor()包装。
6. 经验总结与延伸思考:Agent不是终点,而是新起点
我在金融、物流、制造三个行业落地Agent系统后,越来越确信一件事:LLM Agent的价值,不在于它多像人,而在于它多不像人。人类会疲惫、会情绪化、会跳过检查清单;而一个设计良好的Agent,会永远严格执行if status != "success": retry(),会在timeout_ms毫秒后准时放弃,在rate_limit到达时自动排队。它把“专业服务”从依赖个体经验,变成了可复制、可审计、可压测的标准件。但这不意味着工程师可以躺平——恰恰相反,你的工作重心,从“调参”转向了“定义契约”:和业务方一起,把模糊的“帮我看看合同”拆解成extract_parties、search_risk_clause、compare_with_template三个原子动作;和法务同事一起,把“单方面终止权”这个法律概念,转化为可被BERT模型识别的127个变体短语;和运维团队一起,把“系统要稳定”翻译成P95 latency < 3.8s和tool_success_rate >= 99.2%这两行Prometheus告警。这过程很苦,要开几十次对齐会,要写几百行工具胶水代码,要盯着日志排查三天三夜。但当第一个用户说“这个机器人比我们实习生查得还细”,那种成就感,是调通一个Transformer layer给不了的。最后分享一个小技巧:每周五下午,我会随机抽取10个线上trace_id,手动重放整个执行链路,看哪里有“本可以更友好”的瞬间。比如模型返回{"action":"ASK_USER","thought":"请提供合同ID"},而其实用户上句话就说了“SF20240520001”。这时候,不是怪模型,而是立刻去修Memory Manager的实体提取逻辑——因为真正的智能,藏在那些让机器少问一句、让用户少点一次鼠标的细节里。
