news 2026/9/7 12:56:38

Agent开发入门:用确定性代码驯服LLM的野性

作者头像

张小明

前端开发工程师

1.2k 24
文章封面图
Agent开发入门:用确定性代码驯服LLM的野性

最近老有人问我Agent到底该怎么学,GitHub上那些star过万的agent项目下载下来,跑通demo之后下一步就不知道怎么走了。我给的回答一贯是:先别急着追新框架,回到确定性的代码世界里来,把Agent当成一个普通的软件系统来写,很多问题自然就通了。

这个判断不是拍脑袋。我接触过的Agent项目,十个里有八个翻车不是模型不够聪明,而是工程层面太随意——没有状态机、没有结构化输出校验、没有超时控制、没有回放测试,LLM一抽风整个流程就全线崩溃。这篇文章我想从“代码确定性”的视角重新拆解一遍Agent开发,聊清楚哪些地方必须写死、哪些地方可以交给模型自由发挥,以及怎么用传统软件工程的工具把Agent的野性关进笼子里。内容适合正在学Agent开发、写过几个demo但还没形成工程化思路的开发者。

1. Agent开发真正的门槛在工程而不在模型

很多初学者会把Agent想象成一个“更聪明的AI”,觉得只要提示词写得够好,模型就会自己搞定一切。这是对Agent最大的误解。生产环境跑得稳的Agent,本质上是**“一个由LLM驱动的、受严格约束的软件系统”**——判定条件、流程分支、工具边界、异常处理都是代码写死的,LLM只负责在约束范围内做决策和内容生成。

1.1 先搞清Agent和普通代码的区别

传统应用的行为是确定性(deterministic)的:同样的输入必然得到同样的输出,出了bug可以稳定复现,修完可以用回归测试验证。Agent引入了LLM这个大变量,同样的用户请求,今天和明天返回的思考路径可能完全不同。

但这不代表Agent完全不可控。关键在于你要区分“决策点”和“执行点”:

  • 执行点必须确定性:调用哪个工具、传什么参数、有没有权限、执行超时多久、返回结果符不符合预期格式,这些全部由代码硬性规定
  • 决策点可以非确定性:面对用户的开放式问题,选择哪条路径去完成目标、生成什么话术、优先考虑哪个信息源,这些交给LLM

以我的经验,Agent工程90%的代码量都花在执行点上。状态机、工具注册表、记忆存取、权限校验、日志回放,这些模块和“智能”没有直接关系,但没有它们,模型再聪明也跑不出可靠的业务效果。

1.2 为什么确定性是Agent可落地的前提

一个Agent如果行为和抽卡一样不可预测,没有人敢把它接到生产环境。确定性带来三个直接收益:

第一是可测试。只有行为有确定性边界,你才能写单元测试。比如“用户请求调天气工具时,必须返回结构化JSON,且schema固定”这个规则一旦用代码锁死,就能像测普通函数一样测它。我见过太多团队把Agent当黑盒测——每次跑一遍让“AI自己看结果”,这既不可靠也不可持续。

第二是可调试。线上Agent出错的时候,如果有人问你“上次那个工具调用为什么失败”,你需要的不是“AI觉得是这样”,而是完整的执行轨迹——每一步的输入输出、token消耗、工具返回、异常栈。这一切都依赖你在代码层做好日志和追踪。

第三是可回滚。这可能是Agent项目里最容易被忽略的。模型升级、提示词改了一句、知识库更新了向量,都可能导致Agent行为变化。如果代码层没有版本化的概念,你就无法快速回到“上一个稳定版本”。

2. 从零搭一个Agent项目:先写死再放权

我之前写过一个内部用的客服工单Agent,最初版本非常“自由”——让模型自己决定调哪个API、怎么构造参数、出了问题自己“想办法”。结果就是三天两头出幺蛾子,不是字段拼接错了,就是把用户ID传给订单接口。后来我全部重写,把所有流程改成显式的状态机,LLM只负责“理解用户意图”和“生成最终回复”两个环节,问题立刻少了一半。

2.1 最小Agent骨架:用代码锁定运行流程

这里给出一段简化的Python示例,展示一个Agent骨架的核心结构。重点不在于代码完整可运行,而在于传递“确定性与非确定性分区”的设计思想。

from enum import Enum from typing import Any, Callable, Literal import json import time class AgentState(Enum): IDLE = "IDLE" UNDERSTANDING = "UNDERSTANDING" # 决策点:LLM理解意图 PLANNING = "PLANNING" # 决策点:LLM规划步骤 EXECUTING = "EXECUTING" # 执行点:确定性工具调用 VERIFYING = "VERIFYING" # 执行点:结果校验 FINISHED = "FINISHED" ERROR = "ERROR" class Tool: """工具注册表里的一个工具,所有校验都在这层做""" def __init__(self, name: str, schema: dict, handler: Callable): self.name = name self.schema = schema # JSON Schema self.handler = handler def call(self, arguments: dict) -> dict: # 参数校验:不合格就直接抛异常,不让LLM的幻觉进入业务层 if not validate_json_schema(arguments, self.schema): raise ValueError(f"Tool {self.name} received invalid arguments: {arguments}") return self.handler(arguments) class Agent: def __init__(self, tools: dict[str, Tool], llm_call: Callable, max_steps: int = 5): self.tools = tools self.llm = llm_call self.state = AgentState.IDLE self.memory = [] self.max_steps = max_steps def run(self, user_input: str) -> str: self.state = AgentState.UNDERSTANDING intent = self.llm(f"解析用户意图: {user_input}") # 决策点 if intent == "TICKET_QUERY": self.state = AgentState.EXECUTING result = self._execute_tool("query_ticket", {"ticket_id": extract_id(user_input)}) self.state = AgentState.VERIFYING if not result.get("success"): self.state = AgentState.ERROR return "查询失败,请稍后再试" self.state = AgentState.FINISHED return self.llm(f"根据工单信息生成回复: {json.dumps(result)}") # 决策点 self.state = AgentState.ERROR return "暂不支持该请求"

这段代码的关键在于:意图解析和最终话术生成是LLM的自由场,但状态迁移、工具选择、参数校验、错误处理全是确定性的代码。尤其是validate_json_schema这一步,它就像一个安检门,把模型产出的不规范的参数直接拦在业务逻辑之外。

2.2 工具定义与输出校验是Agent的地基

大部分Agent开发新手在定义工具时只写“函数名+一句话描述”,这远远不够。生产级Agent的工具层至少要包含三个要素:

一是完整的JSON Schema。不只给模型看,还要在代码里真正做校验。以大模型API调用工具为例,你需要规定model这个字段必须是合法模型名、max_tokens必须在合法范围内、参数不能包含非法字符。这些规则全部落地为校验代码,让LLM的每个参数在真实执行前过一遍“安检”。

二是明确的超时与幂等设计。Agent调用外部API是很常见的动作,但外部API可能慢、可能挂、可能返回脏数据。我在实践中发现,给每个工具调用加上超时控制(比如Python的asyncio.wait_for)是Agent稳定性的分水岭。没有超时控制,一次外部API卡死就能把整个Agent拖垮。

三是结构化输出约定。大模型返回的内容必须约束为固定结构(JSON或markdown),避免自由文本带来的歧义。比如让LLM判断“用户是否满意”,不要让它返回一句话,而是强制返回{"satisfied": true/false, "confidence": 0.0~1.0}这样的结构化字段。

提示:我强烈建议在Agent项目里引入Pydantic这类数据校验库。它的核心思想是“数据模式即代码”——定义好模式,框架自动完成校验,任何不符合规范的输入直接拒绝。这能省掉大量手写if-else的样板代码。

3. 记忆、安全与框架选型:三个必须提前做的决策

Agent开发学习路线里,绕不开三个话题:记忆(Memory)、安全(Security)、框架(Framework)。这三个方向如果不在项目初期想清楚,后面改起来成本极高。

3.1 Agent记忆:确定性存储远比“看起来聪明”重要

很多教程把Agent记忆包装得很玄学,什么“长期记忆”“短期记忆”“向量记忆”,好像是一个能思考的灵魂在记事情。工程角度拆开看,记忆的实质就是数据存取——短期记忆是上下文窗口里的对话内容,长期记忆是外部数据库里的历史信息。

我的建议是:优先把记忆当数据库表来设计,而不是当语义空间来设计。也就是说,每条记忆至少要有时间戳、来源(哪一轮对话、哪个模块写入的)、类型(用户信息、中间结果、工具返回)这样确定性的元数据字段。这样才能实现“查三天前用户提到过的偏好”这种场景,否则向量检索出来的内容你根本无法追溯来源。

举个反例:有个团队做了一个“记住用户偏好”的Agent,把所有用户聊天记录向量化存进向量库,需要时做语义相似度检索。结果用户问“我上次说的那个红色的东西”时,Agent召回了一堆语义相近但完全不相关的记录,闹出大笑话。如果当时在存储时把用户ID、时间、会话ID、实体标签这些确定性字段带上,检索精度会高很多,也更容易排查问题。

3.2 Agent框架选型:用确定性视角看待“百家争鸣”

现在Agent框架多如牛毛,LangChain、AutoGen、CrewAI、自研框架都有拥趸。我见过不少人在这个选项上浪费时间,天天看GitHub趋势,框架换了一个又一个,项目却始终停在demo阶段。我的结论比较务实:如果项目复杂度不高,优先自研或薄封装,因为框架层面的抽象会掩盖实际的数据流,出了问题排查成本高。

下面是我基于开发经验的框架选型参考表:

维度自研轻量框架LangChain系AutoGen系
学习成本中(要自己有经验)低(教程多)低(概念多)
确定性控制最强较弱
调试可观测性中(依赖回调)
原型速度
生产稳定性取决于自己

表格结论很明确:原型阶段随便选,但生产落地时要把核心流程从框架里“抠”出来自己掌控。如果你用LangChain做原型,建议把核心的状态流转逻辑抽成独立模块,而不是让框架“帮”你编排Agent。说到底,编排逻辑是业务逻辑的一部分,不应该黑盒化。

3.3 Agent安全:权限白名单与敏感操作护栏

Agent安全这个词听上去很宏大,但落到代码层面无非三件事:工具白名单、参数合法性校验、敏感操作二次确认。LLM的推理能力再强,它终究是个概率模型,可能被提示词注入、可能生成有害内容、可能误调危险工具。

我实际项目中的做法是三层防护:

第一层,工具注册表只暴露白名单内的能力。文件删除、支付、放行权限相关操作绝不直接暴露给模型,一律封装成带鉴权的业务接口。

第二层,给高危操作增加“人工确认”的闸门。比如Agent准备执行一个删除操作时,在状态机里标记为PENDING_CONFIRMATION,必须回到用户侧确认“确定要删除X吗?”后才继续执行。这个闸门是用代码强制实现的,模型无法绕过。

第三层,对所有进入模型的内容做注入检测。具体到工程上,对从外部渠道获取的文本(邮件、网页、用户输入)做过滤,禁止包含“忽略之前的指令”这类提示词注入特征的内容直接拼进系统提示词。

4. 可观测性与测试:把Agent装回“代码的笼子”

前面说了那么多确定性设计,但真正的Agent开发实践中,最能让团队稳定下来的是可观测性与测试体系。这部分工作没有神奇的AI魔法,全是传统软件工程思路的延续。

4.1 日志、追踪与回放:让每次决策都有据可查

Agent跑起来之后,你面前是一个黑盒子:LLM在内部思考了什么、做了哪些工具调用、每一步消耗了多少token,如果不做埋点,这些东西全都无从得知。做生产级Agent,最基础的要求是结构化日志

我在项目里倾向于用JSON格式记录Agent的执行轨迹,核心字段包括:session_id(会话ID)、step(第几步)、state(当时的状态)、llm_response(模型返回)、tool_calls(工具调用列表)、token_usage(token消耗)、latency_ms(耗时)。这样即使Agent后续行为出现偏差,也能像查数据库一样回溯到具体某一步。

在回放方面,有一个特别实用的小技巧:把历史会话的LLM响应和工具返回记录下来,做成一个回放数据集(recording),测试时直接“重放”这些记录,而不需要真的调用昂贵的LLM API。这样既可以复现Bug,又能在不消耗token的情况下跑回归测试。本质上就是电影里的“重演现场”,传统的mock技术。

# 回放模式下的Agent测试思路 class ReplayAgent(Agent): def __init__(self, replay_records: list[dict], **kwargs): super().__init__(**kwargs) self.records = replay_records self._cursor = 0 def llm(self, prompt: str) -> str: # 不真正调大模型,而是返回录制好的响应 record = self.records[self._cursor] assert record["prompt"] == prompt, "prompt mismatch" self._cursor += 1 return record["response"]

这个思路的精髓在于:Agent的代码逻辑被单独隔离出来,不依赖外部模型,也能被自动化测试。当你想改一个状态流转规则时,跑一遍回放测试,就能确认旧的行为没有被破坏。

4.2 写好Agent的测试体系:从单点到端到端

Agent项目的测试可以分层:

单元测试层:针对单个工具函数或状态迁移逻辑。比如测试工具参数校验函数是否拒绝了错误的参数、状态机是否在异常时正确迁移到ERROR态。这一层和传统后端测试几乎一样,应该覆盖大部分代码分支。

集成测试层:用Mock的LLM响应来跑一串Agent流程。重点验证:意图识别后是否正确选择了工具、参数是否按预期组装、错误分支的提示语是否正确。集成测试是Agent项目中最核心的测试层,因为这里最容易出“逻辑对但数据错”的问题。

端到端测试层:真实的LLM调用或接近真实的环境里跑全流程。这一层测试要控制数量,因为每次调用都会消耗时间和token。但绝不能省略,毕竟Mock永远无法完全模拟真实模型的输出分布。

关于Agent测试,我还有一个经验:给LLM调用加一个“seed”或“temperature=0”的选项(不同模型支持程度不同),在测试环境里尽量使用低随机性的配置,这样同一个测试用例的结果更可复现。虽然不能做到100%一致,但已经能大幅提升测试稳定性。

5. 常见问题与排查技巧实录

最后的章节,我整理一下实际项目中高频遇到的问题和排查思路。这些问题几乎每个Agent开发者都会遇到,希望这篇速查表能帮你少走弯路。

5.1 Agent“失控”及执行报错的典型场景

Agent执行报错是Agent开发中的常态,但很多报错信息本身非常抽象。比如Agent execution terminated due to error这种报错,往往是上层框架拦截了异常抛出的结果,真正的错误被吞了。

遇到这类情况,我处理的第一步永远是打开结构化日志,找到出错的环节。我总结了一套具体的排查清单:

典型错误常见原因排查与处理方案
工具参数格式错误LLM未按schema输出参数强化schema示例;代码层严格校验;增加参数修正提示词;极端情况人工介入
Agent循环调用同一工具提示词没有明确终止条件增加最大步骤数限制;在状态机中检测重复动作序列
LLM拒绝执行指令安全提示词与业务指令冲突简化提示词;检查上下文是否被外部注入内容污染
外部API超时/失败外部依赖不稳定增加超时、重试与熔断机制;设计fallback流程
记忆检索出无关内容元数据缺失导致无法精准过滤补充时间戳、会话ID、类型等确定性字段;考虑混合检索

5.2 从“不确定”到“确定”的调试心法

这里是我个人认为Agent开发最有价值也最容易被忽视的经验。面对一个行为异常的Agent,很多开发者的第一反应是“改提示词”,第二反应是“换更强的模型”,第三反应是“加新框架”。我的经验刚好相反:先不动提示词,先把所有输入输出用日志记全,用确定性方法缩小范围

举个例子,你发现Agent有时会返回错误格式的JSON。与其反复修改提示词,不如先做静态检查:每一个进入业务层的LLM输出强制用JSON解析校验,不合格就打回重试或者走兜底路径。你把“可能出问题的环节”变成“不可能出问题的环节”,错误范围自然就收窄了。之后再定位是模型问题还是上下文问题,就容易得多。

另一个实用的调试技巧是人为降级测试。当你怀疑某个工具调用时,直接在测试环境中将这个工具的handler替换为固定返回特定数据,看看Agent剩余流程是否还能稳定走通。这能快速区分“是工具执行的问题”还是“Agent编排逻辑的问题”。

注意:不要指望通过无限堆叠提示词弥补工程缺陷。“让模型多做一步再检查一下”这种思路听起来聪明,实际上增加了不确定性。真正稳妥的方式是用代码把每一步的行为边界锁死,把不确定性压缩到最小范围。

写在最后

我自己从最早写“自由态Agent”到后来转向“确定性优先的Agent工程”,最大的感受是:Agent的智能感来自模型,但可靠性来自代码。再强的大模型,没有工具校验、没有状态机、没有日志回放,生产环境早晚要出大问题。

如果你现在正准备入坑Agent开发,我的建议很直接:先把Python的dataclass、Pydantic的数据校验、状态机设计、结构化日志这套传统后端技能练熟,再谈什么LangChain、AutoGen。你会发现,当你能用确定性的思维把整个Agent系统的边界勾勒清楚的时候,很多之前觉得“玄学”的问题,其实都可以用普通的代码逻辑解决。

最后分享一个我一直在用的小习惯:每次新写一个Agent的模块,先问自己一个问题——“如果这个模块明天不工作了,我能不能通过日志和代码立刻定位到具体是哪一行的责任?”如果答案是肯定的,说明你在正确的方向上。如果不能,说明这个模块还没写好。

版权声明: 本文来自互联网用户投稿,该文观点仅代表作者本人,不代表本站立场。本站仅提供信息存储空间服务,不拥有所有权,不承担相关法律责任。如若内容造成侵权/违法违规/事实不符,请联系邮箱:809451989@qq.com进行投诉反馈,一经查实,立即删除!
网站建设 2026/9/7 12:54:58

个播录屏工具内存管理优化与批量任务稳定性实践

/* MD / 富文本中的 .toc(含博客园搬家等嵌套结构);.toc-box 在侧栏,不受影响 */#content_views .toc,/* 编辑器常在目录前后插入空 p(:empty 仍占 20px),一并去掉避免顶空隙 */#content_views.markdown_views > p:empty:has(+ .toc),#content_views.markdown_views …

作者头像 李华
网站建设 2026/9/7 12:54:34

Wave终端:SSH自动重连与AI报错分析实测与部署指南

/* MD / 富文本中的 .toc(含博客园搬家等嵌套结构);.toc-box 在侧栏,不受影响 */#content_views .toc,/* 编辑器常在目录前后插入空 p(:empty 仍占 20px),一并去掉避免顶空隙 */#content_views.markdown_views > p:empty:has(+ .toc),#content_views.markdown_views …

作者头像 李华
网站建设 2026/9/7 12:53:36

FPGA 100G UDP协议栈移植实战:从开源工程到上板调试

/* MD / 富文本中的 .toc(含博客园搬家等嵌套结构);.toc-box 在侧栏,不受影响 */#content_views .toc,/* 编辑器常在目录前后插入空 p(:empty 仍占 20px),一并去掉避免顶空隙 */#content_views.markdown_views > p:empty:has(+ .toc),#content_views.markdown_views …

作者头像 李华
网站建设 2026/9/7 12:53:22

弹幕指挥AI:构建科研智能体互动直播系统的完整指南

/* MD / 富文本中的 .toc(含博客园搬家等嵌套结构);.toc-box 在侧栏,不受影响 */#content_views .toc,/* 编辑器常在目录前后插入空 p(:empty 仍占 20px),一并去掉避免顶空隙 */#content_views.markdown_views > p:empty:has(+ .toc),#content_views.markdown_views …

作者头像 李华
网站建设 2026/9/7 12:51:20

ESP32-C5-WROOM-1U-N16R8模组详解:双频Wi-Fi 6与802.15.4的IoT融合方案

/* MD / 富文本中的 .toc(含博客园搬家等嵌套结构);.toc-box 在侧栏,不受影响 */#content_views .toc,/* 编辑器常在目录前后插入空 p(:empty 仍占 20px),一并去掉避免顶空隙 */#content_views.markdown_views > p:empty:has(+ .toc),#content_views.markdown_views …

作者头像 李华
网站建设 2026/9/7 12:50:23

大模型Agent开发学习路线:框架选型、Harness工程与TextToSQL落地

/* MD / 富文本中的 .toc(含博客园搬家等嵌套结构);.toc-box 在侧栏,不受影响 */#content_views .toc,/* 编辑器常在目录前后插入空 p(:empty 仍占 20px),一并去掉避免顶空隙 */#content_views.markdown_views > p:empty:has(+ .toc),#content_views.markdown_views …

作者头像 李华