1. 为什么“七要素”讲不清 Agent 的工程实现
市面上讲 AI Agent 的文章,十个里有八个会先甩出一张架构图,然后告诉你 Agent 由“规划、记忆、工具、执行”这几个模块组成。看完之后你确实觉得懂了,但一打开 IDE 准备写代码,发现根本不知道从哪下手。问题出在哪?出在那些文章讲的是 Agent 的“概念模型”,不是“工程模型”。
概念模型回答的是“Agent 像什么”,工程模型回答的是“Agent 在每一行代码里到底做了什么决策”。这两者之间的差距,大概相当于“人脑由神经元组成”和“如何用 PyTorch 搭一个能跑通的分类器”之间的差距。
我刚开始接触 Agent 开发的时候,也走过这段弯路。当时用 LangChain 的initialize_agent三行代码跑通了一个 ReAct 循环,觉得自己已经掌握了 Agent 的精髓。结果老板让我做一个能自动处理客服工单的 Agent,我直接傻眼了——工单分类用哪个模型?工具调用失败了怎么重试?多轮对话的上下文怎么裁剪?用户说“帮我查一下上周的订单”时,Agent 怎么知道要去调订单查询接口而不是物流接口?
这些问题,七要素框架一个都回答不了。
所以这篇文章不打算再重复那些“Agent 是什么”的科普。我想做的是把 Agent 从概念模型翻译成工程模型,具体来说,就是把 Agent 的运行过程拆解成七个必须做决策的节点。每个决策点,我都会说清楚:这里要做什么选择、为什么这个选择重要、选错了会怎样、我实际踩过哪些坑。
这篇文章适合两类人:一是已经了解 LLM 基本用法、准备动手做 Agent 但不知道从哪开始的开发者;二是已经做过一两个 Demo、但发现 Demo 和 production 之间隔着一条鸿沟的工程师。如果你还完全没接触过 LLM,建议先补一下 prompt engineering 和 function calling 的基础,不然读起来会有点吃力。
注意:本文讨论的 Agent 特指基于 LLM 的软件 Agent,不涉及机器人、强化学习训练等方向。文中提到的所有代码示例以 Python 为主,但决策逻辑与语言无关。
2. 决策点一:Agent 的循环边界在哪里
2.1 从“一次调用”到“循环调用”的本质变化
普通 LLM 应用和 Agent 的第一个分水岭,就是有没有循环。普通应用是“输入 → LLM → 输出”,一次调用结束。Agent 是“输入 → LLM → 判断是否需要行动 → 执行行动 → 把结果喂回 LLM → 再判断 → …”,直到满足某个终止条件。
这个循环看起来简单,但工程上第一个要回答的问题就是:循环什么时候停?
我见过太多 Demo 代码是这么写的:
while True: response = llm.invoke(messages) if response.tool_calls: result = execute_tool(response.tool_calls) messages.append(result) else: break这段代码在 Demo 里跑得挺好,因为 Demo 的任务通常很简单,LLM 两三轮就给出最终答案了。但到了生产环境,这段代码就是一颗定时炸弹。我亲身经历过一次:一个用来做数据清洗的 Agent,因为某个工具返回了格式不符合预期的数据,LLM 反复尝试调用同一个工具去“修复”,循环了 47 次才因为 API 超时被迫中断,烧掉了将近 3 美元的 token 费用。
2.2 三种终止条件的优先级设计
一个健壮的 Agent 循环,至少要有三层终止条件,而且它们之间有优先级关系。
第一层是任务完成信号。这是最理想的终止条件,LLM 明确表示“我不需要再调用工具了,这就是最终答案”。在 OpenAI 的 function calling 格式里,这表现为finish_reason是stop而不是tool_calls。在 LangGraph 里,这对应着图走到了 END 节点。
第二层是硬性资源限制。包括最大循环次数、最大 token 消耗、最大执行时间。这三个限制必须同时设置,因为它们的防护维度不同。最大循环次数防的是“LLM 陷入死循环”,最大 token 消耗防的是“单次循环消耗过大”,最大执行时间防的是“某个工具调用卡死”。
第三层是异常熔断。当同一个工具连续失败超过 N 次,或者 LLM 连续输出无法解析的格式超过 N 次,直接中断循环并返回错误状态。这一层最容易被忽略,但恰恰是生产环境最需要的。
我通常的配置是这样的:
| 限制类型 | 推荐值 | 设置理由 |
|---|---|---|
| 最大循环次数 | 10-15 次 | 大多数任务 5 次以内完成,超过 10 次通常意味着出了问题 |
| 最大 token 消耗 | 任务预估的 3 倍 | 留出重试和纠错的空间,但防止无限膨胀 |
| 最大执行时间 | 60-120 秒 | 超过这个时间用户已经流失了,不如快速失败 |
| 同工具连续失败 | 3 次 | 连续失败说明不是偶发问题,继续重试没有意义 |
| 格式解析失败 | 2 次 | LLM 连续输出无法解析的格式,说明 prompt 有问题 |
2.3 循环终止后的状态处理
循环终止不等于任务结束。终止之后,Agent 需要根据终止原因做出不同的响应。
如果是正常完成,直接把最终答案返回给用户。如果是资源限制触发,需要返回一个“部分完成”的结果,并说明在哪里中断了。如果是异常熔断,需要记录详细的错误日志,方便后续排查。
这里有个容易被忽略的细节:循环终止时的消息历史怎么处理。如果直接把整个消息历史返回给用户,用户会看到一堆工具调用的中间过程,体验很差。我的做法是在循环结束后,再调用一次 LLM,让它根据完整的消息历史生成一个面向用户的总结。这次调用不挂载任何工具,纯粹做文本生成。
def finalize(messages, termination_reason): if termination_reason == "completed": return messages[-1].content else: summary_prompt = f"根据以下执行记录,向用户说明任务执行情况。终止原因:{termination_reason}" return llm.invoke(messages + [{"role": "user", "content": summary_prompt}])这个总结步骤看起来多余,但实测下来对用户体验的提升非常明显。用户不需要知道 Agent 内部调了多少次工具,只需要知道“事情办完了没有”和“没办完的话卡在哪了”。
3. 决策点二:工具描述怎么写才能让 LLM 选对
3.1 工具描述不是文档,是 prompt
很多人写工具描述的时候,习惯性地按照 API 文档的风格来写:函数名、参数列表、返回值类型。这种写法对人友好,但对 LLM 不友好。
LLM 选择工具的依据,不是“这个工具能做什么”,而是“这个工具的语义和当前用户意图的匹配程度”。所以工具描述的核心任务不是“说明功能”,而是“建立语义关联”。
举个例子。假设你有一个查询订单状态的工具。API 文档风格的描述是这样的:
def query_order_status(order_id: str) -> dict: """查询订单状态。 Args: order_id: 订单编号 Returns: 包含订单状态的字典 """这种描述的问题在于,它没有告诉 LLM 什么情况下应该用这个工具。用户说“我上周买的东西到哪了”,LLM 需要自己推理出“到哪了”等于“查询订单状态”,然后还要推理出“上周买的东西”对应的是订单编号。这个推理链条越长,出错的概率越大。
更好的写法是在描述里直接嵌入用户可能的表达方式:
def query_order_status(order_id: str) -> dict: """查询订单的物流状态和当前进度。 当用户询问以下类型的问题时使用此工具: - 订单到哪了 / 物流进度 / 什么时候能到 - 我买的东西发货了吗 - 订单状态是什么 Args: order_id: 订单编号,格式为 ORD-XXXXXXXX。 如果用户没有提供订单号,先调用 query_user_orders 获取。 """3.2 工具粒度的取舍:粗一点还是细一点
工具粒度是另一个容易纠结的问题。同样是订单相关的操作,你可以设计成一个工具:
manage_order(action: str, order_id: str, params: dict)也可以拆成五个工具:
query_order_status(order_id) cancel_order(order_id) modify_order_address(order_id, new_address) query_order_logistics(order_id) apply_order_refund(order_id)粗粒度的好处是工具数量少,LLM 选择起来不容易混淆。坏处是参数复杂,LLM 需要正确填充action和params,出错概率高。
细粒度的好处是每个工具的参数简单明确,LLM 容易正确调用。坏处是工具数量多了之后,LLM 可能在相似的工具之间选错。
我的经验是:优先细粒度,但控制总数在 10 个以内。超过 10 个工具,就需要考虑分组或者用层级化的工具选择策略。比如先让 LLM 选择工具类别,再在类别内选择具体工具。
另外有一个实操技巧:给每个工具的描述开头加一个动词短语,比如“查询…”、“创建…”、“修改…”、“删除…”。这样 LLM 在做选择时,可以先匹配动词,再匹配名词,降低选错的概率。
3.3 工具返回值的格式设计
工具返回值的设计,直接影响 LLM 下一轮决策的质量。我见过最糟糕的做法是返回一个巨大的 JSON,里面包含了几十个字段,其中大部分和当前任务无关。
LLM 的上下文窗口是有限的,工具返回的无关信息会挤占有效信息的空间。更严重的是,无关信息可能干扰 LLM 的判断。比如你返回了一个包含error_code: 0的字段,LLM 可能误以为出错了。
我的做法是:工具返回值只包含 LLM 做下一步决策所必需的信息。如果 LLM 需要更多细节,让它再调一次工具去获取。
# 不好的做法:返回完整订单对象 return {"order_id": "...", "status": "...", "items": [...], "payment": {...}, "logistics": {...}, ...} # 好的做法:只返回当前决策需要的信息 return {"status": "已发货", "estimated_arrival": "2024-01-15", "tracking_number": "SF123456"}如果确实需要返回大量数据,可以在返回值里加一个summary字段,用自然语言概括关键信息,把详细数据放在details字段里。LLM 通常会优先读summary,需要时再去看details。
4. 决策点三:记忆系统的分层与裁剪策略
4.1 三种记忆的工程实现差异
Agent 的记忆通常被分为短期记忆、长期记忆和工作记忆。这个分类在概念上很清晰,但工程实现上,它们的差异远比想象中大。
短期记忆就是当前对话的消息历史。实现最简单,就是一个列表,每次 LLM 调用时把整个列表传进去。问题在于,对话轮次多了之后,列表会超出上下文窗口。
长期记忆是跨对话持久化的信息。实现方式通常是向量数据库,把历史对话或知识库内容做 embedding 后存储,需要时通过相似度检索召回。这里的关键决策是:什么时候写入长期记忆?我的做法是每轮对话结束后,用一个轻量级的 LLM 调用判断“这轮对话中是否有值得长期记住的信息”,如果有就写入。
工作记忆是 Agent 在执行当前任务过程中产生的中间状态。比如 Agent 正在处理一个退款请求,已经查了订单状态、确认了退款资格、计算了退款金额,这些中间结果就是工作记忆。工作记忆的实现通常是一个结构化的状态对象,在 LangGraph 里对应 State,在 AutoGen 里对应 context。
4.2 上下文窗口的裁剪:什么时候裁、裁什么
上下文窗口裁剪是 Agent 工程中最容易被低估的环节。很多人等到报context_length_exceeded错误了才想起来处理,这时候已经晚了。
我的策略是主动裁剪,而不是被动截断。具体来说,在每次 LLM 调用之前,先估算当前消息历史的总 token 数。如果超过了模型上下文窗口的 70%,就触发裁剪。
裁剪的优先级是这样的:
- 首先裁剪工具调用的详细返回值,只保留摘要
- 其次裁剪早期的对话轮次,但保留每轮的用户输入和最终回复
- 最后如果还是超限,对最早的对话做摘要压缩
这里有个关键细节:系统提示词和当前任务相关的消息永远不裁。系统提示词定义了 Agent 的行为边界,裁掉之后 Agent 可能做出不符合预期的行为。当前任务相关的消息是决策的直接依据,裁掉之后 Agent 会“失忆”。
def trim_messages(messages, max_tokens=0.7 * context_window): system_msgs = [m for m in messages if m.role == "system"] task_msgs = [m for m in messages if is_task_related(m)] history_msgs = [m for m in messages if m not in system_msgs + task_msgs] # 先保留系统和任务消息,再从最近的对话开始填充 result = system_msgs + task_msgs current_tokens = count_tokens(result) for msg in reversed(history_msgs): msg_tokens = count_tokens(msg) if current_tokens + msg_tokens > max_tokens: break result.insert(len(system_msgs), msg) current_tokens += msg_tokens return result4.3 长期记忆的检索时机与相关性阈值
长期记忆的检索,最常见的错误是“每轮都检索”。这不仅浪费计算资源,还可能引入无关信息干扰 LLM 判断。
我的做法是只在两种情况下触发长期记忆检索:一是用户输入中包含了明确的“回忆”信号,比如“上次”、“之前”、“我记得”;二是当前任务需要个性化信息,比如客服 Agent 需要知道用户的历史偏好。
检索时设置相关性阈值也很重要。向量相似度低于 0.7 的结果,通常和当前问题关系不大,强行塞给 LLM 反而会分散注意力。我一般设置 0.75 作为阈值,低于这个分数的记忆直接丢弃。
还有一个实操技巧:给检索到的记忆加上时间戳和来源标记。LLM 看到“三个月前的对话中用户提到…”和“昨天的对话中用户提到…”,对信息权重的判断是不一样的。
5. 决策点四:规划与执行的分离程度
5.1 一步一规划 vs 先规划再执行
Agent 的规划策略,本质上是在“灵活性”和“效率”之间做权衡。
一步一规划(ReAct 模式)是每执行一步,就让 LLM 重新评估当前状态并决定下一步。这种模式灵活,能应对执行过程中的意外情况。但缺点是 LLM 调用次数多,延迟高,成本也高。
先规划再执行(Plan-and-Execute 模式)是先让 LLM 生成一个完整的执行计划,然后按计划逐步执行。这种模式效率高,LLM 调用次数少。但缺点是如果执行过程中出现意外,计划可能失效,需要重新规划。
我的经验是:任务步骤少于 5 步、且步骤之间依赖关系简单时,用一步一规划;任务步骤多、或者步骤之间有复杂的条件依赖时,用先规划再执行。
但实际项目中,我更多用的是混合模式:先让 LLM 生成一个粗粒度的计划(3-5 个阶段),然后每个阶段内部用一步一规划的方式执行。这样既有全局视野,又有局部灵活性。
5.2 计划失效时的重新规划触发条件
重新规划是 Plan-and-Execute 模式中最关键的决策点。触发条件设得太敏感,会导致频繁重新规划,失去 Plan-and-Execute 的效率优势;设得太迟钝,会导致 Agent 在错误的道路上越走越远。
我通常设置三个触发条件:
第一,工具调用连续失败。同一个工具连续失败两次,说明当前计划中的这一步可能不可行,需要重新规划。
第二,执行结果与预期严重不符。比如计划中假设订单存在,但查询结果显示订单不存在。这种“前提假设被推翻”的情况,必须重新规划。
第三,用户输入了新的约束。比如用户中途说“算了,不退款了,改成换货”。这种情况下,原计划完全失效,必须重新规划。
重新规划时,我会把“已执行的部分”和“失败的原因”作为上下文传给 LLM,让它基于已有信息生成新计划,而不是从零开始。
5.3 规划粒度的控制:太细和太粗都会出问题
规划粒度是另一个需要仔细调节的参数。计划太细,比如“第一步:打开浏览器;第二步:输入网址;第三步:点击搜索框”,LLM 会被琐碎的步骤淹没,而且任何一步的微小偏差都会导致计划失效。计划太粗,比如“第一步:处理用户请求”,又失去了规划的意义。
我的经验值是:每个计划步骤对应 1-3 次工具调用。这个粒度下,计划既有指导性,又不会过于僵化。
另外,计划步骤的描述要用“动词 + 目标”的格式,而不是“动词 + 操作”。比如“查询用户最近的订单”比“调用 query_orders 接口”更好,因为前者描述了目标,LLM 可以选择用不同的工具来实现;后者绑定了具体操作,失去了灵活性。
6. 决策点五:多 Agent 协作的拆分逻辑
6.1 什么时候需要多 Agent
单 Agent 能解决的问题,不要用多 Agent。这是我在多个项目中总结出来的血泪教训。
多 Agent 带来的复杂度是指数级的:通信开销、状态同步、冲突解决、错误传播,每一个都是坑。我见过一个项目,本来单 Agent 已经跑通了,团队觉得“不够优雅”,硬是拆成了三个 Agent,结果调试成本翻了五倍,上线时间推迟了两个月。
那什么时候真的需要多 Agent?我的判断标准是:当不同子任务需要不同的系统提示词、不同的工具集、或者不同的模型时。
比如一个客服系统,售前咨询和售后处理需要的知识库完全不同,工具集也不同。这种情况下,拆成两个 Agent 是合理的。但如果只是任务步骤多,单 Agent 完全能处理,就没必要拆。
6.2 三种协作拓扑的适用场景
多 Agent 的协作拓扑,常见的有三种:流水线、辩论、层级。
流水线是最简单的,Agent A 的输出作为 Agent B 的输入,依次传递。适合有明确阶段划分的任务,比如“需求分析 → 方案设计 → 代码生成 → 代码审查”。
辩论是多个 Agent 对同一个问题给出不同答案,然后通过某种机制(投票、裁判 Agent、LLM as Judge)选出最优解。适合需要多角度思考的任务,比如风险评估、方案选型。
层级是一个 Manager Agent 负责任务分解和调度,多个 Worker Agent 负责执行。适合任务可以并行拆分的场景,比如同时处理多个数据源的数据清洗。
我实际用得最多的是流水线,因为它最简单、最容易调试。辩论模式听起来很美好,但实际效果高度依赖裁判 Agent 的质量,而且成本是单 Agent 的三倍以上。层级模式适合大规模任务,但 Manager Agent 的规划能力往往是瓶颈。
6.3 Agent 间通信的消息格式设计
多 Agent 通信的消息格式,直接决定了协作的效率。我见过最糟糕的设计是让 Agent 之间用自然语言自由对话,结果就是两个 Agent 互相客套了十几轮还没进入正题。
我的做法是结构化消息 + 自然语言补充。结构化部分包含:发送者、接收者、消息类型(请求/响应/通知)、任务 ID、状态。自然语言部分包含具体的任务描述或执行结果。
{ "from": "analyzer", "to": "executor", "type": "task_assignment", "task_id": "task-001", "status": "pending", "payload": { "action": "query_order", "params": {"order_id": "ORD-12345"}, "context": "用户询问订单物流状态,需要查询后回复" } }这种格式的好处是,Agent 可以先解析结构化字段快速理解消息意图,再读自然语言部分获取细节。实测下来,比纯自然语言通信的效率高 3-5 倍。
7. 决策点六:错误处理与重试的边界
7.1 工具调用失败的分类处理
工具调用失败有很多种原因,不同原因需要不同的处理策略。把所有失败都当成“重试”来处理,是最常见的错误。
我把工具调用失败分为四类:
瞬时故障:网络抖动、API 限流、服务暂时不可用。这类失败重试通常能解决,但需要设置退避策略,不能立即重试。
参数错误:LLM 生成的参数格式不对、缺少必填参数、参数值超出范围。这类失败重试没有意义,需要把错误信息返回给 LLM,让它修正参数后重新调用。
业务逻辑错误:比如查询的订单不存在、用户没有权限。这类失败不是“错误”,而是“正常的业务结果”,应该作为工具返回值的一部分返回给 LLM,让它基于这个结果继续决策。
系统级错误:比如工具本身有 bug、依赖的服务挂了。这类失败重试也没用,应该直接中断当前任务,返回错误状态。
def handle_tool_error(error, tool_name, retry_count): if is_transient(error) and retry_count < 3: time.sleep(2 ** retry_count) # 指数退避 return "retry" elif is_param_error(error): return "fix_params" # 把错误信息返回给 LLM elif is_business_error(error): return "continue" # 作为正常结果处理 else: return "abort" # 中断任务7.2 重试的退避策略与最大次数
重试策略的核心是退避。立即重试在瞬时故障场景下几乎必然再次失败,因为故障还没恢复。
我通常用指数退避:第一次重试等 1 秒,第二次等 2 秒,第三次等 4 秒。如果三次都失败,就不再重试了。这个策略在大多数场景下够用。
但有一个例外:API 限流。限流场景下,退避时间应该参考 API 返回的Retry-After头,而不是固定的指数退避。如果 API 没有返回这个头,我通常等 5-10 秒再重试。
最大重试次数我一般设为 3 次。超过 3 次还失败,说明不是偶发问题,继续重试只是浪费时间和 token。
7.3 错误信息如何反馈给 LLM 才有用
错误信息反馈给 LLM 的方式,直接影响 LLM 能否正确修正。我见过最糟糕的做法是把 Python 的 traceback 直接塞给 LLM,LLM 看到一堆堆栈信息完全不知道该怎么办。
有效的错误反馈应该包含三个要素:什么操作失败了、失败的原因是什么、建议怎么修正。
# 不好的做法 return f"Error: {traceback.format_exc()}" # 好的做法 return { "error": "参数格式错误", "detail": "order_id 参数格式不正确,期望格式为 ORD-XXXXXXXX,实际收到 '12345'", "suggestion": "请检查 order_id 是否包含 'ORD-' 前缀,如果用户没有提供完整订单号,先调用 query_user_orders 获取" }这种结构化的错误反馈,LLM 处理起来效率高很多。实测下来,参数错误的修正成功率从 40% 提升到了 85% 以上。
8. 决策点七:可观测性与调试手段
8.1 Agent 调试为什么比普通程序难
普通程序的 bug 是可复现的:同样的输入,同样的执行路径,同样的错误。Agent 的 bug 往往不可复现:同样的输入,LLM 这次选了工具 A,下次选了工具 B,再下次两个都不选,直接编了一个答案。
这种不确定性,让传统的断点调试几乎失效。你没法在 LLM 的“思考过程”里打断点,也没法单步执行看它为什么选了某个工具。
所以 Agent 的调试,核心不是“找到哪一行代码错了”,而是“理解 LLM 在每一步看到了什么、想了什么、为什么这么做”。
8.2 必须记录的五个关键字段
要做到上面说的“理解 LLM 的决策过程”,每次 LLM 调用都必须记录五个字段:
输入消息的完整快照。包括系统提示词、历史消息、当前用户输入。这是理解 LLM 决策的基础。
LLM 的原始输出。包括文本内容和工具调用请求。不要只记录解析后的结果,原始输出里往往藏着关键信息。
工具调用的实际参数。LLM 生成的参数和实际传给工具的参数可能不一致(中间可能有参数校验和转换),两个都要记录。
工具返回的原始结果。包括成功的结果和错误信息。
本次调用的元数据。包括时间戳、耗时、token 消耗、模型名称、温度参数等。
trace = { "timestamp": datetime.now().isoformat(), "messages": messages, "llm_output": { "content": response.content, "tool_calls": response.tool_calls, "finish_reason": response.finish_reason }, "tool_execution": { "tool_name": tool_name, "input_params": actual_params, "output": result, "duration_ms": duration }, "metadata": { "model": model_name, "temperature": temperature, "prompt_tokens": usage.prompt_tokens, "completion_tokens": usage.completion_tokens } }8.3 用 trace 回放定位“LLM 为什么选错工具”
有了完整的 trace,定位问题就简单多了。我通常用“回放”的方式:把某一次失败的 trace 拿出来,逐步检查每个决策点。
比如 Agent 选错了工具,我会按这个顺序排查:
第一步,看系统提示词里工具描述是否清晰。如果工具描述本身就有歧义,LLM 选错是正常的。
第二步,看历史消息里是否有干扰信息。有时候前面的对话里提到了某个工具,LLM 会“惯性”地继续选那个工具。
第三步,看用户输入的表述是否模糊。如果用户说“帮我处理一下”,LLM 确实很难判断该用哪个工具。
第四步,看 LLM 的原始输出。有时候 LLM 在文本部分已经表达了正确的意图,但工具调用部分选错了。这种情况说明工具选择的机制有问题,可能需要调整工具调用的格式或参数。
我踩过的一个坑是:系统提示词里同时存在中英文的工具描述,LLM 在中文对话中倾向于选择中文描述的工具,即使那个工具并不合适。后来统一成中文描述后,问题就消失了。
8.4 生产环境的告警指标设计
Agent 上线之后,需要一套告警指标来及时发现问题。我通常监控这几类指标:
任务完成率。低于 90% 就需要排查,低于 80% 需要立即介入。
平均循环次数。如果突然升高,说明 LLM 的决策质量下降了,可能是模型更新或者 prompt 被改坏了。
工具调用失败率。按工具维度分别监控,某个工具失败率突然升高,通常是那个工具依赖的服务出了问题。
Token 消耗。按任务类型分别监控,突然升高可能是 LLM 陷入了无效循环。
响应延迟 P95。Agent 的延迟通常比普通 API 高,但 P95 超过 30 秒就需要优化了。
这些指标不需要很精确,但必须有。我见过一个团队,Agent 上线后没有任何监控,直到用户投诉量暴涨才发现问题,那时候已经损失了大量用户。
9. 把这七个决策点串起来:一个最小可用的 Agent 骨架
9.1 代码结构的分层设计
把前面七个决策点落地成代码,我通常分成四层:
配置层:存放模型配置、工具定义、循环限制、重试策略等。这一层的内容应该可以通过配置文件修改,不需要改代码。
决策层:包含循环控制、工具选择、规划逻辑、错误处理策略。这一层是 Agent 的“大脑”,决定了 Agent 的行为模式。
执行层:负责实际的工具调用、API 请求、数据处理。这一层是“手脚”,不包含决策逻辑。
观测层:负责 trace 记录、指标上报、日志输出。这一层贯穿整个执行过程,但不影响主流程。
class Agent: def __init__(self, config): self.config = config self.llm = init_llm(config.model) self.tools = init_tools(config.tools) self.tracer = Tracer(config.observability) def run(self, user_input): messages = self.build_initial_messages(user_input) for step in range(self.config.max_steps): response = self.llm.invoke(messages) self.tracer.record_llm_call(messages, response) if not response.tool_calls: return self.finalize(messages, "completed") for tool_call in response.tool_calls: result = self.execute_tool(tool_call) self.tracer.record_tool_call(tool_call, result) messages.append(format_tool_result(result)) return self.finalize(messages, "max_steps_reached")9.2 从 Demo 到生产的检查清单
在把 Agent 部署到生产环境之前,我会过一遍这个检查清单:
- 循环终止条件是否设置了至少三层防护
- 工具描述是否包含了用户可能的表达方式
- 上下文窗口裁剪策略是否经过压力测试
- 长期记忆的检索阈值是否经过调优
- 规划失效的重新触发条件是否合理
- 多 Agent 通信是否有结构化格式
- 工具调用失败是否按类型分别处理
- 重试策略是否有退避机制
- 错误信息反馈是否包含修正建议
- trace 记录是否包含五个关键字段
- 告警指标是否覆盖了核心维度
这个清单看起来长,但每一条都是踩过坑之后加上的。我建议至少在前三个项目里逐条检查,后面形成肌肉记忆了就可以凭直觉判断了。
9.3 我踩过的三个印象最深的坑
第一个坑是工具描述里的“万能工具”。早期我设计了一个execute_sql工具,让 LLM 直接写 SQL 查询数据库。结果 LLM 经常写出性能极差的查询,有一次甚至写了一个全表扫描的 JOIN,直接把数据库拖垮了。后来我把这个工具拆成了十几个具体的查询工具,每个工具对应一个预定义的查询模板,问题才解决。
第二个坑是上下文窗口的“隐形溢出”。我用的是按字符数估算 token 的方式,结果中文和英文的 token 比例差异很大,导致估算严重偏低。有一次消息历史实际 token 数已经超过了模型上限,但估算值还在安全范围内,LLM 调用直接报错。后来换成了 tiktoken 精确计算,问题才解决。
第三个坑是重试策略的“雪崩效应”。我给所有工具调用都加了重试,结果某个下游服务响应变慢时,所有 Agent 都在重试,把下游服务彻底压垮了。后来加了熔断机制:当某个工具的失败率超过 50% 时,直接跳过该工具,不再重试。
这三个坑的共同点是:问题不在 Agent 本身,而在 Agent 和外部系统的交互边界上。Agent 的工程实现,难点往往不在“智能”部分,而在“工程”部分。
10. 关于 Agent 工程实现的一点个人体会
做了两年多 Agent 相关的项目,我最大的体会是:Agent 的智能程度,取决于工程细节的扎实程度,而不是 prompt 的精妙程度。
我见过太多团队把大量时间花在调 prompt 上,试图让 LLM “更聪明”。但实际效果提升最大的,往往是那些看起来不起眼的工程改进:把工具描述写得更清晰、把错误信息反馈得更有用、把上下文裁剪得更合理。
LLM 本身的能力是固定的,你能做的只是让它在你的系统里发挥出应有的水平。这就像招了一个很聪明的员工,但如果你不给他清晰的岗位职责、不给他及时的工作反馈、不给他合理的任务分配,他的聪明才智也发挥不出来。
Agent 工程的核心,就是给 LLM 创造一个能发挥其能力的工作环境。这个环境包括:清晰的工具边界、合理的记忆管理、健壮的错误处理、完整的可观测性。这些做好了,Agent 的表现自然就上去了。
最后分享一个我常用的调试技巧:把 Agent 的 trace 打印出来,假装自己是一个新员工,看能不能根据这些信息完成同样的任务。如果你自己看着 trace 都觉得信息不够、逻辑不清,那 LLM 大概率也会困惑。这个技巧帮我发现了很多 prompt 和工具描述里的问题。