1. 从"能跑"到"能扛":AI Agent 的可靠性分水岭
很多人第一次接触 AI Agent,都是从一段几十行的脚本开始的:给模型一个系统提示词,挂上几个工具函数,然后写一个while循环,让模型自己决定下一步干什么。跑通的那一刻确实很爽——模型会自己调用搜索、自己读文件、自己算数,看起来像个能独立思考的小助手。但只要把它放到稍微真实一点的场景里,问题就来了:循环停不下来、工具调用参数乱填、上下文越滚越长最后直接超限、同一个任务两次跑出来的结果完全不一样。
这些问题的根源,其实不在于模型本身有多"笨",而在于大多数人把 Agent 当成了一个"提示词工程"问题,而它本质上是一个系统可靠性问题。一个能跑的 Agent 和一个能扛的 Agent,中间隔着的不是更聪明的模型,而是一整套围绕循环、状态、错误处理和边界控制的设计。
这篇内容想聊的就是这条分水岭。我会从最小的 Agent 循环讲起,把Agent Loop、Function Calling、Prompt设计、Workflow编排这几个核心概念拆开揉碎,然后一步步补上让系统变可靠的那些关键机制。适合已经跑通过 Demo、但一上真实任务就翻车的开发者,也适合正在评估要不要自建 Agent 中台的技术负责人。读完之后,你应该能清楚地知道:一个 Agent 从"玩具"到"生产可用",中间到底要补哪几块。
2. 最小 Agent 循环到底长什么样
2.1 一个循环、三个角色、四个步骤
抛开所有框架和封装,一个 AI Agent 的最小循环其实只有三个角色在参与:模型(Model)、工具(Tools)、运行时(Runtime)。模型负责决策,工具负责执行,运行时负责把两者串起来并维护状态。所谓 Agent Loop,就是这三者之间不断往复的一个过程。
把它拆成步骤,大概是这样的:
- 运行时把当前对话历史(包括系统提示词、用户输入、之前的工具调用结果)打包,发给模型。
- 模型返回一个响应,这个响应要么是"我要调用某个工具,参数是这些",要么是"我的最终答案是这些"。
- 如果模型要调用工具,运行时执行对应函数,把结果作为一条新消息追加到对话历史里。
- 回到第 1 步,直到模型给出最终答案,或者触发某个终止条件。
就这么简单。用伪代码表示,核心逻辑不超过二十行:
messages = [system_prompt, user_input] while not done: response = model.chat(messages) if response.has_tool_call: result = execute_tool(response.tool_call) messages.append(response) messages.append(tool_result_message(result)) else: done = True final_answer = response.content这个结构就是所有 Agent 框架的"内核"。LangChain 的 AgentExecutor、LangGraph 的 StateGraph、各种自研中台,剥到最里面都是这个循环。理解这一点很重要,因为后面所有的可靠性问题,本质上都是这个循环在真实环境下暴露出来的边界问题。
2.2 为什么"最小循环"必然不可靠
最小循环能跑通,是因为它假设了几件在现实中几乎不成立的事:模型每次都会返回合法格式、工具调用永远成功、任务总能在几步内结束、上下文永远不会超限。任何一条被打破,循环就会出问题。
我见过最典型的翻车场景是这样的:模型调用了一个搜索工具,返回结果特别长,几千字直接塞进上下文;模型基于这个结果又调了一次搜索,又塞几千字;循环到第五六轮的时候,上下文已经逼近模型上限,要么直接报错,要么模型开始"遗忘"前面的指令,行为变得莫名其妙。这时候你去看日志,会发现循环本身没写错,错的是它没有任何"刹车"和"减负"机制。
所以从最小循环到可靠系统,要补的不是一个功能,而是一组机制。下面几节我会逐个拆开讲。
3. Function Calling:Agent 的手脚,也是最容易出错的地方
3.1 工具定义的质量直接决定调用质量
Function Calling是 Agent 和外部世界交互的接口。模型根据你提供的工具描述,决定调不调、调哪个、传什么参数。这里有个很多人忽略的事实:模型对工具的调用准确率,很大程度上取决于工具描述写得好不好,而不是模型有多强。
一个糟糕的工具定义长这样:
{ "name": "search", "description": "搜索东西", "parameters": { "query": {"type": "string"} } }问题在哪?"搜索东西"这个描述太模糊,模型不知道搜什么、什么时候该搜、搜出来的结果是什么形态。参数query也没说明格式要求。结果就是模型可能在该用别的工具时用了它,或者传一个含糊的自然语言句子进去。
一个合格的描述应该是这样的:
{ "name": "search_knowledge_base", "description": "在企业内部知识库中检索文档。当用户询问产品政策、流程规范、历史工单等内部信息时使用。不适用于实时新闻或通用常识查询。", "parameters": { "query": { "type": "string", "description": "检索关键词,建议使用名词短语,避免完整句子。例如 '报销流程' 而不是 '我想知道报销流程是什么'" }, "top_k": { "type": "integer", "description": "返回结果数量,默认 5,最大 20" } } }差别在于:明确了使用场景(什么时候用)、排除场景(什么时候不用)、参数格式(怎么填)。这三样东西写清楚,模型的调用准确率会有肉眼可见的提升。
3.2 参数校验不能只靠模型自觉
即使描述写得再好,模型偶尔还是会传错参数——类型不对、必填项缺失、值超出范围。这时候如果运行时直接把参数丢给函数执行,轻则报错中断,重则产生副作用(比如删错了数据)。
可靠的做法是在工具执行前加一层参数校验。这层校验不需要多复杂,用 JSON Schema 校验库就能搞定:
from jsonschema import validate, ValidationError def execute_tool(tool_call): schema = TOOL_SCHEMAS[tool_call.name] try: validate(instance=tool_call.arguments, schema=schema) except ValidationError as e: return { "error": f"参数校验失败: {e.message}", "hint": "请检查参数格式后重试" } return TOOLS[tool_call.name](**tool_call.arguments)关键在于:校验失败时不要把异常直接抛出去中断循环,而是把错误信息作为工具结果返回给模型。模型看到"参数校验失败:top_k 必须是整数",下一轮大概率会自己修正。这就是 Agent 自我纠错能力的来源——不是模型天生会纠错,而是你把错误信息喂回给它了。
3.3 工具结果的"瘦身"处理
工具返回的结果往往比模型需要的多得多。一个数据库查询可能返回几十条记录,一个网页抓取可能返回整页 HTML。如果原样塞进上下文,几轮下来就会把上下文撑爆。
我的做法是在工具层做结果裁剪,而不是等到上下文管理阶段再处理。具体来说:
- 对列表类结果,只保留前 N 条,并明确告诉模型"还有 X 条未显示"。
- 对长文本,做摘要或截断,保留关键字段。
- 对结构化数据,只提取模型决策需要的字段,丢掉冗余元数据。
def trim_result(raw, max_items=5, max_chars=2000): if isinstance(raw, list): trimmed = raw[:max_items] note = f"(共 {len(raw)} 条,显示前 {max_items} 条)" return {"items": trimmed, "note": note} text = str(raw) if len(text) > max_chars: return text[:max_chars] + f"...(已截断,原文 {len(text)} 字)" return text这个处理看起来不起眼,但它直接决定了你的 Agent 能跑多少轮。上下文是 Agent 最稀缺的资源,工具层是控制它的第一道闸门。
4. Prompt 设计:不是写作文,是写接口文档
4.1 系统提示词的本质是"行为契约"
很多人写系统提示词,习惯写成一段热情洋溢的角色扮演:"你是一个专业的、乐于助人的、经验丰富的助手……"这种写法对聊天机器人可能有用,但对 Agent 来说几乎没用,因为 Agent 需要的是明确的行为规则,不是人设。
Agent 的系统提示词应该像一份接口文档,回答几个问题:你能用哪些工具、什么情况下用哪个、遇到错误怎么办、什么情况下必须停下来问人、输出格式是什么。举个例子:
你是一个任务执行 Agent,可以使用以下工具: - search_kb: 检索内部知识库 - query_db: 查询业务数据库 - send_email: 发送邮件 行为规则: 1. 涉及数据查询时,优先使用 query_db,不要凭记忆回答。 2. 每次工具调用后,检查返回结果是否为空。为空时最多重试 2 次,仍为空则告知用户。 3. 发送邮件前必须向用户确认收件人和内容,不得自行决定。 4. 如果连续 3 次工具调用都没有推进任务,停止并汇报当前状态。 5. 最终回答使用简洁的中文,不要复述工具原始输出。这份提示词里没有一句废话,每一条都是可执行、可验证的规则。模型拿到这样的指令,行为会稳定得多。
4.2 提示词里的"防呆"设计
真实环境里,模型会遇到各种边界情况。提示词里提前把这些情况写清楚,能省掉大量调试时间。我通常会加这几类"防呆"条款:
- 空结果处理:工具返回空怎么办。
- 重试上限:同一个操作最多试几次。
- 循环检测:连续无进展时如何退出。
- 确认机制:哪些操作必须先问人。
- 格式约束:最终输出必须符合什么结构。
这些条款看起来是在"限制"模型,实际上是在给它一个安全网。没有这些约束,模型在遇到意外时会自由发挥,而自由发挥在 Agent 场景里往往意味着不可预测。
4.3 提示词版本管理:别让改动变成玄学
Agent 的提示词会随着调试不断修改。如果没有版本管理,很容易出现"昨天还好好的,今天改了两个字就崩了"的情况,而且根本回不去。
我的建议是把提示词当成代码来管:存在文件里、进版本控制、每次改动写清楚改了什么、为什么改。更进一步,可以在运行时记录每次请求用的是哪个版本的提示词,出问题时能快速定位。
PROMPT_VERSION = "v2.3.1" # 日志里带上版本号 logger.info(f"agent_run prompt_version={PROMPT_VERSION} task_id={task_id}")这个习惯在单人开发时可能觉得多余,但一旦有多人协作或者需要长期维护,它能救命。
5. Workflow 编排:什么时候该让模型自由发挥,什么时候该收权
5.1 Agent 和 Workflow 不是对立的
社区里经常有"Agent 派"和"Workflow 派"的争论,其实这俩不是二选一的关系。Workflow 是确定性的流程编排,Agent 是模型驱动的动态决策。一个成熟的系统往往是两者的混合:主干流程用 Workflow 固定下来,需要灵活判断的节点交给 Agent。
举个实际例子。一个客服工单处理系统,主干流程是固定的:接收工单 → 分类 → 检索知识库 → 生成回复 → 人工审核 → 发送。这个主干用 Workflow 编排最合适,因为步骤明确、顺序固定。但"分类"和"生成回复"这两个节点,内部逻辑复杂、情况多变,适合用 Agent 来处理。
这样设计的好处是:该确定的地方确定,该灵活的地方灵活。全用 Agent,系统不可控;全用 Workflow,遇到意外情况就卡死。
5.2 用状态机约束 Agent 的行为边界
如果你用 LangGraph 这类工具,本质上就是在用状态机来编排 Agent。每个节点是一个处理步骤,边定义了流转条件。这种结构天然适合做可靠性控制,因为你可以:
- 在节点之间插入校验节点,检查上一步输出是否合法。
- 定义条件边,根据状态决定走哪条路。
- 设置最大步数,防止无限循环。
- 加入人工介入节点,在关键决策点暂停。
from langgraph.graph import StateGraph, END def should_continue(state): if state["steps"] > 10: return "abort" if state["needs_human"]: return "human_review" if state["task_done"]: return END return "agent_step" graph = StateGraph(AgentState) graph.add_node("agent_step", agent_node) graph.add_node("human_review", human_node) graph.add_node("abort", abort_node) graph.add_conditional_edges("agent_step", should_continue)这段代码的价值不在于用了什么框架,而在于它把"什么时候停"这件事从模型手里拿回来,交给了确定性的代码逻辑。模型可以决定"下一步做什么",但"能不能继续做"由状态机说了算。
5.3 并发场景下的 Agent 编排
热词里有人问"AI Agent 怎么扛并发",这确实是个绕不开的问题。单机跑一个 Agent 和同时跑一百个 Agent,是完全不同的挑战。
核心矛盾在于:Agent 是有状态的、长时运行的,而并发要求资源可复用、请求可隔离。我的经验是分三层处理:
第一层,会话隔离。每个 Agent 实例的状态(对话历史、中间变量)必须独立存储,不能共享内存。用 session_id 做 key,状态存 Redis 或数据库。
第二层,资源池化。模型调用、数据库连接、工具执行这些重资源,用连接池管理,避免每个 Agent 都新建连接。
第三层,限流与排队。Agent 任务往往耗时长,不能无限接收。用队列做缓冲,超出容量时排队或拒绝,而不是硬扛。
async def handle_agent_task(task, semaphore): async with semaphore: # 限制并发数 state = await load_state(task.session_id) result = await run_agent_loop(state, task.input) await save_state(task.session_id, state) return result这套组合下来,单机能稳定支撑的并发数会比裸跑高一个数量级。关键不是某个技巧,而是把"状态"和"资源"这两件事分开管理。
6. 让循环真正可靠的几个关键机制
6.1 终止条件:给循环装上刹车
最小循环最大的隐患就是没有明确的终止条件。模型可能陷入"调用工具 → 结果不满意 → 再调用 → 还不满意"的死循环。可靠系统必须有多重终止条件:
| 终止条件 | 触发逻辑 | 处理方式 |
|---|---|---|
| 任务完成 | 模型返回最终答案 | 正常结束 |
| 步数超限 | 循环次数 > 阈值 | 中断并汇报 |
| 无进展检测 | 连续 N 步状态无变化 | 中断并汇报 |
| 错误累积 | 连续错误 > 阈值 | 中断并告警 |
| 超时 | 总耗时 > 阈值 | 中断并汇报 |
这几条里,"无进展检测"最容易被忽略,但也最重要。判断"无进展"可以很简单:比较连续两步的工具调用和结果,如果完全一样,就认为卡住了。
def is_stuck(history, window=3): recent = history[-window:] signatures = [hash_tool_call(h) for h in recent] return len(set(signatures)) == 16.2 上下文管理:Agent 的"内存"要主动回收
上下文是 Agent 最宝贵的资源,也是最容易失控的地方。除了前面说的工具结果瘦身,还需要在循环层面做上下文管理。常见策略有三种:
- 滑动窗口:只保留最近 N 轮对话,老的丢弃。简单但会丢失早期信息。
- 摘要压缩:把早期对话总结成一段摘要,保留关键信息。效果好但需要额外模型调用。
- 分层记忆:把重要信息(如任务目标、关键结论)单独存起来,每轮都带上;过程性内容按需检索。
我一般用"分层记忆 + 滑动窗口"的组合:任务目标和已确认的关键结论始终保留,中间的工具调用过程只留最近几轮。这样既控制了长度,又不会丢掉核心信息。
6.3 错误处理:把异常变成模型的输入
前面提过,工具执行失败时不要把异常抛出去,而是把错误信息返回给模型。这个原则要贯彻到整个循环里。模型调用失败、格式解析失败、超时——所有这些都应该被捕获,转成结构化的错误信息喂回循环。
def safe_step(state): try: response = model.chat(state.messages) return parse_response(response) except ModelError as e: return {"error": str(e), "retryable": True} except ParseError as e: return {"error": f"响应格式错误: {e}", "retryable": True}配合重试上限,这套机制能让 Agent 在遇到偶发问题时自愈,而不是直接崩溃。实测下来,加了这层之后,任务成功率能提升不少,尤其是那些依赖外部服务的场景。
6.4 可观测性:出问题时你得知道发生了什么
Agent 的行为是动态的,出了问题很难复现。所以日志和追踪不是可选项,是必需品。至少要记录:
- 每一轮的输入消息、模型输出、工具调用和结果。
- 每一步的耗时。
- 终止原因。
- 提示词版本、模型版本。
有了这些,出问题时能完整回放整个执行过程,定位到具体是哪一步、哪个决策出了问题。没有这些,调试 Agent 基本靠猜。
7. 从 Demo 到生产:我踩过的几个坑
7.1 别在提示词里塞太多工具
刚开始做 Agent 时,我恨不得把所有能用的工具都挂上去,觉得工具越多能力越强。结果发现,工具数量超过十几个之后,模型的调用准确率明显下降——它开始混淆相似的工具,或者干脆不用工具凭记忆回答。
后来我的做法是按场景分组:不同任务类型加载不同的工具子集。客服场景只挂知识库和工单工具,数据分析场景只挂数据库和图表工具。工具少了,模型的选择反而更准。
7.2 模型返回的 JSON 不一定合法
即使你要求模型"只返回 JSON",它也可能返回带 markdown 代码块包裹的、带解释文字的、或者干脆格式错误的 JSON。所以解析这一步必须做容错:
def parse_json_safe(text): text = text.strip() if text.startswith("```"): text = text.split("```")[1] if text.startswith("json"): text = text[4:] try: return json.loads(text) except json.JSONDecodeError: # 尝试提取第一个 JSON 对象 match = re.search(r'\{.*\}', text, re.DOTALL) if match: return json.loads(match.group()) raise这段代码看起来丑,但能救回大量本来会失败的请求。
7.3 测试用例要覆盖"坏路径"
大多数人测试 Agent 时,只测正常流程:给个明确任务,看它能不能完成。但真正出问题的是坏路径:工具返回空、模型返回格式错误、任务本身无解、用户中途改需求。这些场景必须专门构造测试用例。
我的做法是维护一个"坏路径测试集",每次改提示词或工具定义,都跑一遍这个集合,确保没有引入回归。这个习惯让我的 Agent 在真实环境里的稳定性提升非常明显。
7.4 人工介入不是失败,是设计
有些任务就是不该让 Agent 全自动完成,比如涉及资金、涉及对外发送、涉及不可逆操作。在这些节点设置人工确认,不是 Agent 能力不足,而是负责任的设计。把"什么时候必须问人"写进提示词和状态机,比事后追责有用得多。
8. 写在最后的一点个人体会
做 Agent 这几年,我最大的感受是:Agent 的难点从来不在模型,而在系统。模型能力每年都在涨,但循环控制、状态管理、错误处理、边界约束这些东西,是无论模型多强都必须自己搭的。一个提示词写得再漂亮,如果没有可靠的循环和终止机制,放到真实场景里照样翻车。
如果你现在正卡在"Demo 能跑但一上真实任务就崩"的阶段,我的建议是先把最小循环的终止条件和错误处理补上,再考虑加工具、换模型、上框架。顺序反了,会走很多弯路。工具和框架是放大器,它们放大的是你系统的可靠性,而不是弥补它。