AI Agent 工程实现:七个关键决策点与核心循环设计

发布时间:2026/10/31 12:20:33
AI Agent 工程实现:七个关键决策点与核心循环设计

1. 为什么“七要素”讲不清 Agent 的工程实现

市面上讲 AI Agent 的文章,十个里有八个会先甩出一张架构图,然后告诉你 Agent 由“规划、记忆、工具、执行”这几大块组成。看完之后你确实觉得懂了,但一打开 IDE 准备写代码,发现根本不知道第一行该敲什么。问题出在哪?出在“要素”是静态的切面,而 Agent 是动态的运行时。

我最早接触 Agent 开发的时候,也是被各种“要素论”带着走。把 LLM 当大脑、把工具当手脚、把记忆当笔记本,这个比喻本身没错,但它解释不了工程中最要命的那几个问题:LLM 什么时候该停下来?工具调用的结果怎么塞回上下文?循环到第几轮该强制退出?这些问题的答案不在“要素”里,而在“决策点”上。

所谓决策点,就是 Agent 运行过程中必须做出判断的那些时刻。一个 Agent 从收到用户输入到给出最终回复,中间会经历至少七个关键决策:要不要调用工具、调哪个工具、参数怎么填、结果怎么解析、要不要继续循环、上下文怎么裁剪、什么时候终止。这七个决策点串起来,才是 Agent 真正的工程骨架。七要素告诉你 Agent 由什么组成,七个决策点告诉你 Agent 怎么跑起来。前者是解剖图,后者是行车记录仪。

这篇文章面向的是已经了解 LLM 基本调用、准备动手搭一个能跑通的 Agent 的开发者。我不会只给你概念,而是把每个决策点的实现逻辑、常见坑、以及我在实际项目中踩过的教训都摊开讲。读完你至少能明白一件事:Agent 的复杂度不在于模型多强,而在于这七个决策点你处理得够不够细。

提示:本文讨论的 Agent 特指基于 LLM 的对话式智能体,不涉及强化学习训练出来的决策智能体,也不涉及多智能体协作编排的进阶话题。先把单 Agent 的循环跑通,比什么都重要。

2. 第一个决策点:这轮到底要不要调工具

Agent 和普通 Chatbot 最本质的区别,就是它能在对话过程中决定“我要去查一下”或者“我要去算一下”。但这个决定不是白来的,它直接决定了你这轮响应的延迟、成本和可靠性。我见过不少新手写的 Agent,不管用户问什么都先调一遍工具,结果用户只是说了句“你好”,Agent 也去查了一遍天气 API,纯属浪费。

2.1 工具调用的触发信号从哪里来

目前主流的做法有两种。一种是靠 LLM 自己判断,你在 system prompt 里写清楚“当用户的问题需要实时数据或外部计算时,调用相应工具”,然后让模型输出一个结构化的 tool_call 指令。另一种是在代码层面做前置路由,用规则或小模型先判断意图,再决定要不要把工具列表传给 LLM。

这两种方式各有取舍。纯靠 LLM 判断的好处是灵活,用户说“帮我看看明天出门要不要带伞”,模型能自己推理出需要调天气工具。坏处是不稳定,同一个问题换个问法,模型可能就不调了。代码层前置路由的好处是可控,你可以针对特定关键词或意图做硬编码,坏处是维护成本高,意图一多规则就爆炸。

我实际项目里的做法是混合:先用一个轻量的意图分类器做粗筛,把明显不需要工具的对话(比如闲聊、追问、确认)直接拦掉,剩下的再交给 LLM 做精细判断。这样既控制了成本,又保留了灵活性。意图分类器不需要多强,一个微调过的小模型甚至关键词匹配都能顶一阵子。

2.2 不调工具的时候 Agent 在干什么

这里有个容易被忽略的点:不调工具不等于什么都不做。Agent 在不调工具的时候,实际上是在做“基于已有上下文的直接生成”。这时候它的行为跟普通 Chatbot 没区别,但你的工程实现上要注意,不要因为走了不同分支就产生两套完全不同的响应格式。

我踩过的坑是:调工具的分支返回的是结构化 JSON,不调工具的分支返回的是纯文本,结果前端解析的时候经常报错。后来统一成所有响应都走同一个输出协议,工具调用的结果也包装成文本再返回,问题才解决。这个决策点的工程意义在于:你要保证无论走哪条路,下游拿到的数据格式是一致的。

注意:如果你的 Agent 支持多轮对话,还要考虑“上一轮调了工具,这一轮用户只是追问”的情况。这时候不应该重新调工具,而是应该基于上一轮的工具结果继续推理。判断依据是上下文里是否已经存在可用的工具返回结果。

3. 第二个决策点:在多个工具之间怎么选

当你的 Agent 挂载了超过三个工具之后,选错工具就成了最常见的故障。我见过一个客服 Agent,用户问“我的订单到哪了”,它去调了“修改收货地址”的工具,原因仅仅是两个工具的描述里都出现了“订单”这个词。这不是模型笨,是工具描述没写好。

3.1 工具描述的质量决定选择准确率

工具描述不是写给人看的文档,是写给 LLM 看的“使用说明书”。我总结了一个工具描述的三段式模板:第一句说这个工具做什么,第二句说什么时候该用,第三句说什么时候不该用。举个例子,一个查询订单状态的工具,描述应该写成:“查询指定订单的当前物流状态。当用户询问订单进度、配送位置、预计送达时间时使用。当用户要修改订单信息或取消订单时,不要使用此工具。”

这个“不要使用”的第三句,是很多人会漏掉的。LLM 在多个相似工具之间做选择时,负向约束往往比正向描述更有效。你告诉它“什么时候别用”,比告诉它“什么时候用”更能减少误选。

3.2 工具数量超过七个之后怎么办

有个经验数据:当工具数量超过七个,LLM 的选择准确率会明显下降。这不是模型能力问题,是上下文里工具描述太多,注意力被稀释了。解决办法有两个方向。一是做工具分组,先让 LLM 选组,再在组内选具体工具。二是做工具检索,把用户 query 和工具描述都向量化,先召回最相关的三到五个工具,再让 LLM 从中选。

我倾向于第二种,因为实现简单且效果稳定。具体做法是维护一个工具描述的向量库,每次用户输入进来,先做一次相似度检索,把 top-k 的工具塞进 prompt。这样 LLM 每次面对的候选工具不超过五个,选择准确率能拉回来不少。代价是多了一次向量检索的延迟,但相比选错工具导致的返工,这个代价完全值得。

策略适用场景优点缺点
全量工具列表工具数 ≤ 5实现简单,无额外延迟工具多了准确率下降
工具分组工具有明显类别层级清晰,可解释需要维护分组逻辑
向量检索召回工具数 > 7准确率高,扩展性好增加检索延迟和向量库维护

3.3 工具选择失败后的兜底逻辑

再好的选择逻辑也会有选错的时候。关键是选错之后怎么办。我的做法是在工具执行层加一层校验:每个工具在执行前先检查参数是否满足前置条件,不满足就返回一个结构化的错误信息,而不是直接抛异常。这个错误信息会回到 LLM 的上下文里,让它有机会重新选择或向用户澄清。

比如用户说“帮我订一张明天去北京的票”,Agent 选了“查询航班”工具,但没填出发城市。工具返回“缺少出发城市参数,请向用户确认”。LLM 收到这个结果后,下一轮就会问用户“请问您从哪个城市出发”。这比直接报错给用户看要优雅得多。

4. 第三个决策点:工具参数怎么填才不出错

参数填充是 Agent 工程里最琐碎但也最容易出 bug 的环节。LLM 生成参数的本质是“根据上下文猜”,猜对了皆大欢喜,猜错了就是一场灾难。我经历过最离谱的一次是用户说“帮我给张三发个消息”,Agent 把消息内容填成了“张三”两个字,因为它在参数映射时把收件人名字误当成了消息体。

4.1 参数 schema 的设计原则

工具的参数定义要用 JSON Schema,这是常识。但很多人写 schema 的时候太随意,导致 LLM 理解偏差。我的经验是三条:第一,每个参数都要有 description,而且 description 里要写清楚格式要求,比如“日期格式为 YYYY-MM-DD”;第二,能枚举的参数不要用自由文本,比如“优先级”就应该是 enum: [high, medium, low];第三,必填参数和可选参数要严格区分,required 数组里只放真正必须的。

还有一个细节:参数名不要用缩写。你写addr,LLM 可能理解成 address,也可能理解成 addressee。写全address就没歧义了。同理,qty不如quantity,desc不如description。多打几个字符,省下的是调试时间。

4.2 参数校验应该放在哪一层

参数校验有两个位置可选:一是 LLM 生成参数后、工具执行前,在代码层做一次校验;二是工具内部自己做校验。我的建议是两层都做,但职责不同。代码层做格式校验,比如日期格式对不对、枚举值在不在范围内、必填项有没有缺。工具内部做业务校验,比如查询的订单号是否存在于数据库、用户是否有权限操作这个资源。

代码层校验失败时,不要直接抛异常,而是把校验错误信息返回给 LLM,让它重新生成参数。这相当于给 LLM 一次“改作业”的机会。我实测下来,大部分参数格式错误都能在第二轮修正。如果连续两轮都校验失败,再降级为向用户澄清。

4.3 多参数之间的依赖关系怎么处理

有些工具的参数之间存在依赖关系。比如“查询航班”工具,如果你填了“经停城市”参数,那么“直飞”参数就必须为 false。这种跨参数的约束,JSON Schema 本身表达不了,需要在工具描述里用自然语言写清楚,或者在代码层做联合校验。

我的做法是在工具描述的最后加一段“参数约束”说明,用自然语言列出所有跨参数规则。LLM 对自然语言约束的遵守程度,实测比想象中好。当然,代码层的联合校验也不能省,这是最后一道防线。

提示:如果你的工具参数里包含用户敏感信息(比如身份证号、手机号),记得在日志里做脱敏。我见过因为日志打印了完整参数导致信息泄露的案例,这个坑踩一次就够了。

5. 第四个决策点:工具返回结果怎么塞回上下文

工具调完了,结果拿到了,然后呢?直接原样塞回 LLM 的上下文?如果你这么做,迟早会遇到上下文爆炸的问题。一个查询数据库的工具返回了 200 条记录,每条记录 50 个字段,这一下就是上万 token。LLM 的上下文窗口再大也扛不住这么造。

5.1 结果裁剪的三种策略

第一种是截断,只取前 N 条记录或前 M 个字符。简单粗暴,但可能丢掉关键信息。第二种是摘要,用另一个 LLM 调用把工具结果压缩成一段简短描述。效果好但增加成本和延迟。第三种是结构化提取,根据当前对话的意图,只提取相关字段。

我常用的是第三种加第一种的组合:先根据意图提取相关字段,如果提取后还是太长,再做截断。比如用户问“我的订单到哪了”,工具返回了订单的完整信息,我只提取“物流状态”和“预计送达时间”两个字段塞回上下文,其余字段丢弃。这样既保留了关键信息,又控制了 token 消耗。

5.2 工具结果的格式对 LLM 理解的影响

工具返回的结果格式,直接影响 LLM 能不能正确理解。我试过返回纯 JSON,也试过返回自然语言描述,实测下来自然语言描述的理解准确率更高。比如同样是订单状态,返回{"status": "shipped", "eta": "2024-01-15"}和返回“订单已发货,预计 2024年1月15日 送达”,后者 LLM 的后续推理明显更顺畅。

所以我现在的一个习惯是:工具内部返回结构化数据,但在塞回上下文之前,做一层“自然语言化”的转换。这个转换不需要 LLM,用模板拼接就行。比如“订单状态:{status},预计送达:{eta}”。这样既保留了结构化的准确性,又让 LLM 更容易理解。

5.3 工具报错信息怎么处理

工具执行失败是常态,网络超时、权限不足、资源不存在,各种情况都有。关键是怎么把错误信息传递给 LLM。我的原则是:错误信息要包含“发生了什么”和“可以怎么办”两部分。比如“查询失败:订单号不存在。请向用户确认订单号是否正确”。

只告诉 LLM“查询失败”是不够的,它不知道下一步该干嘛。加上“请向用户确认订单号”之后,LLM 就知道该走澄清分支了。这个细节看起来小,但对 Agent 的自主恢复能力影响很大。

6. 第五个决策点:循环该继续还是该停

Agent 的核心是一个循环:思考、行动、观察、再思考。但这个循环不能无限转下去。我见过一个 Agent 因为工具一直返回空结果,LLM 一直重试,转了 30 多轮才因为超时被强制终止,烧了一堆 token 不说,用户体验也极差。

6.1 循环终止的四个条件

我在工程里会设置四个终止条件,满足任意一个就退出循环。第一,LLM 输出了最终答案,没有新的工具调用请求。第二,循环轮数达到了预设上限,我一般设 10 轮,复杂场景最多 15 轮。第三,连续两轮的工具调用结果完全相同,说明陷入了死循环。第四,累计 token 消耗超过了预算阈值。

这四个条件里,最容易被忽略的是第三个。LLM 有时候会陷入“调工具、得到结果、觉得不对、再调同一个工具”的循环。检测到连续两轮结果相同就强制退出,能省下不少冤枉钱。

6.2 循环轮数上限设多少合适

这个问题没有标准答案,取决于你的任务复杂度。我统计过自己项目里的数据:简单问答类任务,平均 1.2 轮就结束;需要多步推理的任务,平均 3.5 轮;最复杂的任务也没超过 8 轮。所以 10 轮的上限对绝大多数场景都够用了。

如果你不确定,可以先设一个宽松的上限(比如 15 轮),然后在上线后统计实际轮数分布,再根据 P99 值来调整。我一般会把上限设在 P99 轮数的 1.5 倍左右,既不会误杀正常请求,又能拦住异常循环。

6.3 循环过程中的上下文管理

每轮循环都会往上下文里追加新的消息,上下文会越来越长。如果不做管理,到第五六轮的时候可能就撑爆窗口了。我的做法是维护一个“滑动窗口 + 关键信息锚点”的上下文结构。滑动窗口保留最近 N 轮的消息,关键信息锚点则保存最初的任务描述和已经确认的事实。

具体实现上,我会在上下文里单独维护一个“任务状态”区域,记录当前任务的目标、已经收集到的信息、还缺什么。每轮循环开始时,把这个状态区域放在 prompt 的最前面,让 LLM 始终记得自己要干什么。这样即使滑动窗口把早期的消息挤掉了,任务的核心信息也不会丢。

终止条件触发时机处理方式
最终答案输出LLM 无工具调用请求正常返回
轮数上限达到预设轮数返回当前最佳结果并提示
结果重复连续两轮工具结果相同中断循环并返回
Token 超预算累计消耗超阈值中断并降级处理

7. 第六个决策点:上下文窗口快满了怎么裁

上下文管理是 Agent 工程里最不性感但最重要的工作。LLM 的上下文窗口是有限资源,而 Agent 的循环机制天然会消耗大量上下文。你不管理它,它就管理你——以报错的方式。

7.1 哪些内容必须保留,哪些可以丢

我的优先级排序是这样的:System prompt 必须保留,这是 Agent 的行为准则。当前任务的目标描述必须保留,这是方向。最近两轮的工具调用和结果必须保留,这是短期记忆。已经确认的关键事实必须保留,比如用户告诉你的订单号、地址等信息。其余的历史对话,可以按时间远近逐步丢弃。

这个排序背后的逻辑是:Agent 需要知道“我是谁、我要干什么、我刚做了什么、我知道了什么”。至于“我三分钟前说过什么客套话”,丢了就丢了。

7.2 摘要压缩的具体操作

当上下文实在装不下的时候,就需要做摘要压缩。我的做法是把最早的那部分对话拿出来,用一个单独的 LLM 调用生成一段摘要,然后用摘要替换掉原始对话。摘要的 prompt 我会写成:“请用不超过 100 字总结以下对话中与当前任务相关的关键信息,忽略寒暄和重复内容。”

这个操作的成本是一次额外的 LLM 调用,但相比上下文溢出导致的请求失败,这个成本是值得的。我一般会在上下文使用率达到 70% 的时候触发摘要压缩,留 30% 的余量给后续循环。

7.3 上下文裁剪的时机选择

裁剪的时机也很关键。我试过在每轮循环开始前裁剪,也试过在上下文快满的时候裁剪。实测下来,后者更好。因为每轮开始前裁剪,可能会把上一轮刚产生的有用信息裁掉。而在快满的时候裁剪,能最大化利用每一轮的信息价值。

具体实现上,我会在每次准备调用 LLM 之前检查当前上下文的 token 数,如果超过窗口的 70%,就触发裁剪流程。裁剪后再调用 LLM。这样既保证了不溢出,又尽量保留了完整信息。

注意:不同模型对 token 的计算方式不同,中文和英文的 token 比例也不一样。不要凭感觉估算,用 tokenizer 实际算。我见过因为估算偏差导致线上请求失败的案例,这个坑不值得踩。

8. 第七个决策点:什么时候该向用户求助

Agent 不是万能的,有些问题它自己搞不定。这时候是硬着头皮瞎编一个答案,还是老老实实向用户求助?我的原则是:宁可承认不知道,也不要编造一个错误答案。因为编造的答案会污染后续对话,用户基于错误信息继续追问,整个对话就崩了。

8.1 什么情况下应该触发求助

我设定了三种触发求助的场景。第一,连续两轮工具调用都失败,且失败原因不是参数格式问题。第二,LLM 输出的答案置信度低,这个可以通过让 LLM 自评置信度来实现。第三,用户的问题超出了所有已挂载工具的能力范围。

第三种情况最常见。用户问了一个你的 Agent 根本没有工具能回答的问题,这时候正确的做法是告诉用户“我目前没有能力处理这个问题”,而不是让 LLM 自由发挥。自由发挥的结果往往是幻觉,而幻觉在 Agent 场景下的危害比普通对话更大,因为用户会以为 Agent 真的去查了。

8.2 求助话术的设计

求助话术不是简单说一句“我不知道”。好的求助话术应该包含三部分:承认能力边界、说明已经尝试了什么、给出替代建议。比如“我目前没有查询实时汇率的工具,无法直接回答您的问题。您可以尝试在银行 App 中查看,或者告诉我您想换算的金额和币种,我帮您做数学计算。”

这个话术的好处是,它没有把用户堵死,而是给了一个替代路径。用户即使得不到直接答案,也知道下一步该干嘛。这比冷冰冰的“我不知道”体验好得多。

8.3 求助之后的对话怎么继续

用户收到求助信息后,可能会换一种问法,或者提供更多信息。这时候 Agent 需要能够接住。我的做法是在求助之后,把“当前任务未完成”这个状态记录下来,下一轮用户输入进来时,优先检查是否是对上一个未完成任务的补充。

比如用户说“那帮我算一下 100 美元等于多少人民币”,Agent 检测到这是对上一个汇率问题的补充,就会直接调用计算工具,而不是重新走一遍意图识别。这个状态传递的机制,能让多轮对话的体验连贯很多。

9. 把七个决策点串起来:一个最小可运行 Agent 的骨架

讲了七个决策点,现在把它们串起来看。一个最小可运行的 Agent,代码结构大概长这样:外层是一个 while 循环,循环内部依次执行“构建上下文、调用 LLM、解析输出、判断是否调工具、执行工具、处理结果、检查终止条件”。七个决策点分别对应循环里的不同判断分支。

9.1 核心循环的伪代码结构

def run_agent(user_input, max_turns=10): context = build_initial_context(user_input) for turn in range(max_turns): # 决策点6:上下文裁剪 context = trim_context_if_needed(context) # 调用 LLM response = call_llm(context) # 决策点1:是否调工具 if not response.has_tool_call: # 决策点7:是否需要求助 if response.confidence < THRESHOLD: return ask_user_for_help(response) return response.content # 决策点2:选了哪个工具 tool = select_tool(response.tool_call) # 决策点3:参数校验 params = validate_params(response.tool_call.params) if not params.valid: context.append(error_message(params.error)) continue # 执行工具 result = execute_tool(tool, params) # 决策点4:结果处理 processed_result = process_result(result) context.append(processed_result) # 决策点5:循环终止检查 if should_terminate(context, turn): break return fallback_response(context)

这段伪代码里,每个决策点都对应一个函数调用或条件判断。实际工程中,每个函数内部还有大量细节,但骨架就是这个样子。你把每个决策点的逻辑都实现清楚,Agent 就能跑起来了。

9.2 每个决策点的测试策略

Agent 的测试比普通函数难,因为 LLM 的输出是不确定的。我的做法是分层测试。决策点1和2用 mock LLM 测试,固定 LLM 的输出,验证分支逻辑是否正确。决策点3和4用单元测试,参数校验和结果处理都是纯函数,容易测。决策点5和6用集成测试,模拟多轮循环,验证终止条件和上下文裁剪是否按预期工作。决策点7用端到端测试,真实调用 LLM,验证求助话术是否合理。

这个测试策略的核心思想是:把不确定的部分(LLM 调用)隔离出来,把确定的部分(分支逻辑、参数校验、结果处理)测透。这样即使 LLM 行为有波动,核心逻辑的稳定性也有保障。

9.3 上线之后要盯哪些指标

Agent 上线不是终点,是起点。我一般会盯四个指标:工具调用成功率、平均循环轮数、上下文溢出率、用户求助触发率。工具调用成功率低于 90% 说明工具描述或参数 schema 有问题。平均循环轮数突然升高说明 LLM 可能陷入了某种循环。上下文溢出率大于 0 说明裁剪策略需要调整。用户求助触发率过高说明 Agent 的能力覆盖不够。

这四个指标里,我最看重的是平均循环轮数。它像一个体温计,能最早反映出 Agent 的健康状况。轮数突然升高,往往是某个工具开始返回异常结果,或者 LLM 的 prompt 需要更新了。

10. 几个我踩过的坑和对应的解法

最后分享几个实际项目中踩过的坑,都是文档里不会写但真实会遇到的。

第一个坑是工具调用的并发问题。当 LLM 在一轮里请求调用多个工具时,如果你串行执行,延迟会累加。我后来改成并行执行,但要注意工具之间是否有依赖关系。没有依赖的工具并行调,有依赖的串行调。判断依赖关系这件事,我目前还是靠人工标注,没找到特别好的自动化方案。

第二个坑是 LLM 输出的 tool_call 格式不稳定。同一个模型,有时候返回 JSON,有时候返回带 markdown 代码块的 JSON,有时候还会在 JSON 前后加解释文字。我的解法是在解析层做容错,先用正则提取 JSON 部分,再尝试解析。如果解析失败,把原始输出返回给 LLM 让它重新生成。这个重试逻辑加上之后,解析失败率从 5% 降到了 0.1% 以下。

第三个坑是上下文里的角色混淆。Agent 循环中会产生 user、assistant、tool 三种角色的消息。如果角色标注错了,LLM 会把工具返回的结果当成用户说的话,导致后续推理完全跑偏。我的做法是在每次追加消息时,严格检查角色字段,并且写了一个断言函数在开发环境里做校验。上线前跑一遍全量测试用例,确保没有角色错乱的情况。

第四个坑是 token 计数的偏差。不同模型对同一段文本的 token 计数不一样,同一个模型对中英文混合文本的计数也不一样。我一开始用字符数估算,结果线上频繁溢出。后来换成用模型自带的 tokenizer 实际计算,问题才解决。这个坑的教训是:不要估算,要实测。

这四个坑有一个共同点:它们都不是算法问题,而是工程问题。Agent 的难点从来不在模型有多聪明,而在这些边边角角的工程细节你有没有处理到位。七个决策点,每一个拆开看都不复杂,但合在一起就是一个完整的运行时系统。你把每个决策点的边界条件都想清楚,Agent 的稳定性自然就上来了。

我在实际项目里的体会是,Agent 开发最花时间的不是写核心循环,而是处理各种异常分支。正常流程可能一百行代码就写完了,异常处理要写五百行。但正是这五百行,决定了你的 Agent 是能 demo 还是能上线。