Agent 开发这两年从概念验证卷到了工程落地但很多人写出来的东西要么是个套壳聊天框要么是把 LangChain 的文档抄了一遍真到要自己从零搭一个能跑、能调试、能扩展的小 Agent 时反而不知道从哪下手。我最近花了两周时间参照 pi-agent 的设计思路用 Java 从零撸了一个最小可用的 Agent 内核跑通了 ReAct 循环、工具调用、记忆管理和容错重试这几块硬骨头。这篇文章就把整个拆解过程摊开讲——不是教你调 API而是讲清楚一个 Agent 内核到底由哪几块组成、每块为什么这么设计、以及我在实现过程中踩过的那些坑。适合已经了解 LLM 基本调用、想深入 Agent 底层机制的 Java 后端同学也适合做 AI 应用但一直被框架黑盒困扰的开发者。1. 为什么我不建议一上来就用现成框架1.1 框架帮你省的事恰恰是你最该搞懂的事市面上主流的 Agent 框架无论是 Python 系的还是 Java 系的核心都在做三件事把 LLM 调用封装成统一接口、把工具注册和调度抽象成插件体系、把多轮对话的状态管理做成开箱即用。听起来很美好但你有没有遇到过这种情况——Agent 跑着跑着突然不调工具了或者调了工具但参数传错了你翻遍框架文档也找不到问题出在哪因为中间层太多了日志被埋得深不见底。我自己就遇到过。之前用某个框架做一个数据库查询 Agent模型明明应该调queryUserById这个工具结果它一直在那自言自语我需要查询用户信息就是不触发工具调用。排查了半天才发现是框架在 prompt 模板里加了一段系统提示把工具调用的触发条件给覆盖了。这种问题你不把 Agent 的核心循环拆开看一遍根本定位不到。参照 pi-agent 的思路自己实现一遍最大的价值不是我也有个 Agent 了而是你对整个链路的每一环都有掌控力。模型为什么这么回、工具为什么没触发、记忆为什么丢了你都能在代码里找到对应的那一行。1.2 pi-agent 的设计哲学最小内核 可插拔pi-agent 这个项目本身不算大但它的架构思路很清晰内核只负责思考-行动-观察这个循环其他所有东西——LLM 提供商、工具集、记忆存储、输出解析——全部通过接口抽象出去。这意味着你可以用最少的代码跑通主流程然后按需替换任何一个环节。我把它拆成了四个核心模块模块职责可替换点LLM Client与模型交互发送 prompt 接收响应支持不同模型提供商Tool Registry注册、查找、执行工具工具定义与执行分离Memory存储对话历史和中间状态内存/Redis/数据库Agent Loop驱动 ReAct 循环循环策略可定制这个拆分的好处是你调试的时候可以单独 mock 任何一个模块。比如怀疑是 prompt 的问题就把 LLM Client 换成一个返回固定响应的 mock看循环逻辑对不对怀疑是工具的问题就单独写个测试直接调 Tool Registry。1.3 Java 做 Agent 开发的现实考量有人会问Agent 开发不是 Python 的天下吗为什么用 Java这个问题我在团队里也被问过。实际情况是大部分企业的后端系统是 Java 写的Agent 要落地就得跟现有系统集成。你不可能为了做个 Agent 把整个订单系统用 Python 重写一遍。Java 做 Agent 有几个实际优势线程模型成熟做并发工具调用很自然类型系统强工具参数的校验和序列化不容易出错生态里 HTTP 客户端、JSON 处理、连接池这些基础设施非常完善。当然劣势也有就是 LLM 相关的 SDK 生态不如 Python 丰富很多新出的模型可能没有官方 Java SDK得自己封装 HTTP 调用。但这个封装成本其实不高后面我会讲怎么用统一的接口把这层屏蔽掉。2. ReAct 循环的骨架思考、行动、观察到底怎么串2.1 从一次完整的 Agent 执行说起先看一个最简场景用户问北京今天天气怎么样Agent 需要调用天气查询工具然后根据返回结果生成回答。这个过程在 ReAct 模式下拆成三步第一步是Thought思考模型分析用户意图决定需要调用天气工具并生成工具调用参数。第二步是Action行动Agent 执行工具调用拿到原始数据。第三步是Observation观察把工具返回结果喂回给模型模型基于这个结果生成最终回答。关键在于这三步不是一次完成的而是一个循环。模型可能在观察之后发现信息不够再次发起工具调用。比如查完天气发现用户还问了适合穿什么模型可能再调一个穿衣建议工具。循环的终止条件是模型输出了一个不包含工具调用的最终回答。我在实现的时候把每一轮循环的状态定义成一个AgentStep对象public class AgentStep { private String thought; // 模型的思考过程 private String action; // 决定调用的工具名 private MapString, Object actionInput; // 工具参数 private String observation; // 工具返回结果 private boolean isFinal; // 是否是最终回答 }这个对象贯穿整个循环每一步都往里追加最后形成一个完整的执行轨迹。这个轨迹非常重要后面做调试、做评估、做 prompt 优化都靠它。2.2 Prompt 模板的设计让模型愿意调工具ReAct 能不能跑起来七成看 prompt。我试过好几种模板写法最后稳定下来的结构是这样的你是一个可以调用工具的智能助手。你可以使用以下工具 {tool_descriptions} 请严格按照以下格式回复 Thought: 你的思考过程 Action: 工具名称 Action Input: 工具参数JSON格式 当你已经获得足够信息可以回答用户时使用以下格式 Thought: 我已经知道答案了 Final Answer: 你的最终回答 用户问题{user_input}这里有几个细节值得说。第一Thought字段不是给用户看的是给模型自己打草稿用的实测下来有这个字段比没有的准确率高不少因为模型在生成行动之前先想了一遍。第二Action Input强制要求 JSON 格式这样解析起来简单但要注意模型有时候会输出带 markdown 代码块的 JSON解析前得先清洗。第三Final Answer的触发条件要写清楚否则模型可能一直循环调工具停不下来。提示工具描述的质量直接决定模型会不会正确调用。每个工具的描述要包含什么时候用和参数是什么意思不要只写工具名。2.3 输出解析比想象中更容易翻车模型输出是自然语言你要从里面提取出结构化的Thought、Action、Action Input这个过程叫输出解析。我一开始用正则表达式硬匹配结果发现模型的花样太多了——有时候用中文冒号有时候加粗了字段名有时候把 JSON 包在代码块里。后来我改成了更宽容的解析策略先按行扫描找到以Thought:、Action:、Action Input:开头的行允许前面有空格和 markdown 符号然后取冒号后面的内容。对于Action Input先尝试直接 JSON 解析失败的话去掉代码块标记再试再失败就尝试修复常见的 JSON 错误比如单引号、尾逗号。private String extractField(String text, String fieldName) { Pattern pattern Pattern.compile( (?i)^\\s*[*#]*\\s* fieldName \\s*[:]\\s*(.)$, Pattern.MULTILINE ); Matcher matcher pattern.matcher(text); if (matcher.find()) { return matcher.group(1).trim(); } return null; }这个正则允许字段名前有 markdown 符号冒号中英文都支持。实测下来覆盖率能到 95% 以上剩下的 5% 走 fallback 逻辑——如果解析不出Action就把整个输出当作Final Answer处理至少不会让循环卡死。2.4 循环终止与最大步数保护Agent 循环最怕的就是死循环。模型可能因为工具返回结果不符合预期反复调用同一个工具。我设了两个保护一是最大步数限制默认 10 步超过就强制终止并返回当前最好的结果二是重复检测如果连续两步调用了同一个工具且参数相同直接中断。if (stepCount maxSteps) { log.warn(Agent 达到最大步数限制强制终止); return buildFallbackAnswer(steps); } if (isDuplicateAction(currentStep, lastStep)) { log.warn(检测到重复工具调用中断循环); return buildFallbackAnswer(steps); }buildFallbackAnswer的逻辑是把已有的观察结果拼起来让模型做最后一次总结而不是直接抛异常。用户体验上宁可给一个不完美的回答也不要给一个报错。3. 工具系统注册、描述、执行三件事3.1 工具的定义接口设计决定扩展性工具系统的核心是一个接口我定义得非常简单public interface AgentTool { String getName(); String getDescription(); MapString, Object getParameterSchema(); String execute(MapString, Object parameters); }四个方法分别对应工具名模型调用时用的标识、工具描述给模型看的说明、参数 schema用于生成 prompt 和校验、执行逻辑。这个接口的好处是新增一个工具只需要实现这四个方法不用改任何其他代码。参数 schema 我用的是一个简化的 Map 结构而不是完整的 JSON Schema因为完整的 JSON Schema 写起来太啰嗦而模型其实只需要知道参数名、类型和是否必填就够了。生成 prompt 的时候我把 schema 转成一段人类可读的描述工具名queryWeather 描述查询指定城市的当前天气情况 参数 - city (String, 必填): 城市名称如北京 - unit (String, 可选): 温度单位celsius 或 fahrenheit默认 celsius这种格式模型理解起来很自然实测比直接塞 JSON Schema 的调用准确率更高。3.2 工具注册表查找与执行分离工具注册表负责管理所有可用工具提供按名查找和执行的能力public class ToolRegistry { private final MapString, AgentTool tools new ConcurrentHashMap(); public void register(AgentTool tool) { tools.put(tool.getName(), tool); } public AgentTool getTool(String name) { return tools.get(name); } public String executeTool(String name, MapString, Object params) { AgentTool tool tools.get(name); if (tool null) { return 错误未找到工具 name; } try { return tool.execute(params); } catch (Exception e) { return 工具执行失败 e.getMessage(); } } }注意executeTool里我把异常捕获了返回错误信息而不是抛出。这是因为工具执行失败在 Agent 场景里是常态——模型可能传错参数、外部 API 可能超时——这些错误应该作为观察结果喂回给模型让模型决定下一步怎么办而不是让整个 Agent 崩溃。3.3 工具执行的安全边界工具执行是 Agent 里风险最高的环节因为模型生成的参数是不可控的。我做了三层防护第一层是参数校验。执行前检查必填参数是否存在、类型是否匹配。比如queryWeather的city参数如果是 null直接返回错误不进入执行逻辑。第二层是超时控制。每个工具执行包在一个带超时的 Future 里默认 30 秒。超时后返回工具执行超时让模型决定是否重试。ExecutorService executor Executors.newSingleThreadExecutor(); FutureString future executor.submit(() - tool.execute(params)); try { return future.get(30, TimeUnit.SECONDS); } catch (TimeoutException e) { future.cancel(true); return 工具执行超时30秒; }第三层是权限隔离。涉及敏感操作的工具比如写数据库、发请求要单独标记在注册时就限定可调用的场景。这个在实际落地时非常重要后面讲安全那节会展开。3.4 工具描述写得好模型调用错得少这是我踩坑最多的地方。一开始我写的工具描述很简洁比如查询天气结果模型经常把城市名和省份名搞混或者该传city的时候传了location。后来我把描述改成了查询指定城市的当前天气情况参数 city 必须是城市名称不要传省份或地区名调用准确率明显提升。还有一个技巧是给参数加示例。比如city参数的描述写成城市名称如北京、上海模型看到示例后传参格式会规范很多。这些细节看起来不起眼但直接决定了 Agent 的可用性。4. 记忆管理不只是存对话历史4.1 短期记忆与长期记忆的分工Agent 的记忆分两层。短期记忆是当前任务的执行轨迹包括每一轮的 Thought、Action、Observation这个必须完整保留因为模型需要看到之前的步骤才能决定下一步。长期记忆是跨会话的信息比如用户的偏好、历史交互摘要这个不是每个 Agent 都需要但做个人助手类应用时很关键。我实现的时候短期记忆就是一个ListAgentStep每次循环往里追加。长期记忆抽象成一个MemoryStore接口public interface MemoryStore { void save(String sessionId, String key, String value); String load(String sessionId, String key); ListString search(String sessionId, String query, int limit); }默认实现是内存版生产环境可以换成 Redis 或向量数据库。4.2 上下文窗口的裁剪策略这是实际跑起来才会遇到的问题多轮对话之后历史消息越来越长很快就超出了模型的上下文窗口。你不能简单地把最早的消息删掉因为那里面可能有重要的工具调用结果。我的策略是分层裁剪系统 prompt 和工具描述永远保留最近的 N 轮对话完整保留更早的对话做摘要压缩用模型生成一段简短总结替代原文。摘要的触发阈值设为上下文窗口的 70%留 30% 给模型生成响应。if (estimatedTokens contextWindow * 0.7) { String summary summarize(oldSteps); compressedHistory buildCompressedHistory(summary, recentSteps); }estimatedTokens的估算我用的是字符数除以 2 的粗略方法中文场景下这个比例还算准。精确计算需要引入 tokenizer但会增加依赖看你的精度要求。4.3 记忆写入的时机与去重长期记忆什么时候写我的做法是在 Agent 循环结束时让模型自己判断这轮对话有没有值得记住的信息。具体是在最终回答之后追加一个隐藏的 prompt请判断以上对话中是否有值得长期记住的用户信息如果有以 JSON 格式输出如果没有输出 NONE。这个做法比每轮都写要克制得多避免记忆库被垃圾信息撑爆。去重的话写入前先做一次相似度检索如果已有高度相似的记忆就更新而不是新增。5. 容错与重试让 Agent 在异常中活下来5.1 模型输出格式错误的兜底前面提到输出解析可能失败这时候不能直接报错。我的兜底逻辑是如果解析不出Action字段就把整个输出当作Final Answer。如果解析出了Action但Action Input解析失败就返回一个格式错误提示给模型让它重新生成。if (action null) { // 没有工具调用当作最终回答 return AgentResult.finalAnswer(rawOutput); } if (actionInput null) { // 有工具调用但参数解析失败让模型重试 steps.add(AgentStep.error(参数格式错误请重新生成 JSON 格式的参数)); continue; }这个重试也要有次数限制同一个工具连续失败 3 次就跳过避免无限循环。5.2 工具调用失败的降级处理工具失败分几种情况工具不存在、参数校验失败、执行超时、执行抛异常。这几种我都统一转成文本错误信息喂回给模型但错误信息的措辞有讲究。比如工具不存在时我会把可用工具列表一起返回未找到工具 xxx可用工具queryWeather, queryTime, calculate。这样模型下一轮就能选对工具。执行超时的错误信息里会带上可以尝试简化参数后重试引导模型换个方式调用。实测下来模型看到这种带建议的错误信息自我修复的成功率比看到裸错误信息高很多。5.3 LLM 调用本身的容错模型 API 也会挂。网络超时、限流、服务端错误这些都要处理。我的做法是加一层带指数退避的重试int retries 0; while (retries maxRetries) { try { return llmClient.call(prompt); } catch (RateLimitException e) { Thread.sleep((long) Math.pow(2, retries) * 1000); retries; } catch (Exception e) { if (retries maxRetries - 1) throw e; retries; } }限流错误退避时间长一点其他错误短一点。重试次数默认 3 次超过就返回一个服务暂时不可用的友好提示。5.4 超时与中断的优雅处理整个 Agent 执行也要有总超时我设的是 60 秒。超过就中断循环返回已有的部分结果。中断的时候要注意线程清理特别是工具执行用的线程池不清理会泄漏。try { return future.get(60, TimeUnit.SECONDS); } catch (TimeoutException e) { future.cancel(true); executor.shutdownNow(); return buildPartialResult(steps); }buildPartialResult会把已经完成的步骤整理成一个回答虽然不完整但比直接报错强。6. 从零跑通一个最小 Agent 的完整步骤6.1 环境准备与依赖选择我用的是 Java 17 Maven核心依赖就三个HTTP 客户端用 OkHttp轻量、API 简洁JSON 处理用 Jackson生态成熟日志用 SLF4J Logback。没有引入任何 Agent 框架全部手写。dependency groupIdcom.squareup.okhttp3/groupId artifactIdokhttp/artifactId version4.12.0/version /dependency dependency groupIdcom.fasterxml.jackson.core/groupId artifactIdjackson-databind/artifactId version2.17.0/version /dependency选 OkHttp 而不是 Java 11 自带的 HttpClient是因为 OkHttp 的超时控制和连接池配置更灵活做 LLM 调用这种长连接场景更合适。6.2 LLM Client 的封装LLM Client 的核心是把 prompt 发出去、把响应拿回来。我定义了一个统一接口public interface LlmClient { String chat(ListMessage messages, LlmOptions options); }Message包含 role 和 contentLlmOptions包含 temperature、maxTokens 等参数。具体实现类负责把统一格式转成各家 API 的格式。这样换模型只需要加一个实现类Agent 循环代码完全不用动。调用的时候要注意设置合理的超时。LLM 响应通常要几秒到几十秒我把连接超时设 10 秒读超时设 60 秒。6.3 组装 Agent 主循环主循环的伪代码大概是这样public AgentResult run(String userInput) { ListAgentStep steps new ArrayList(); String currentInput userInput; for (int i 0; i maxSteps; i) { String prompt buildPrompt(currentInput, steps); String output llmClient.chat(prompt); AgentStep step parseOutput(output); if (step.isFinal()) { return AgentResult.success(step.getFinalAnswer(), steps); } String observation toolRegistry.executeTool( step.getAction(), step.getActionInput() ); step.setObservation(observation); steps.add(step); } return AgentResult.maxStepsReached(steps); }这个循环看起来简单但每一行背后都有前面讲的那些细节。buildPrompt要处理上下文裁剪parseOutput要处理格式容错executeTool要处理超时和异常。6.4 跑通第一个 Demo天气查询 Agent我写的第一个测试用例就是天气查询。注册一个 mock 的天气工具返回固定数据然后问北京今天天气怎么样。第一次跑的时候模型没调工具直接编了个答案。排查发现是 prompt 里工具描述不够明确改成你必须使用工具查询真实天气数据不要自己编造之后模型就乖乖调工具了。这个 demo 虽然简单但把整个链路都跑通了prompt 构建、模型调用、输出解析、工具执行、结果回填、最终回答生成。后面加复杂工具、加记忆、加容错都是在这个骨架上扩展。6.5 调试技巧把每一步都打出来Agent 调试最有效的方法就是把每一轮的 prompt、模型原始输出、解析结果、工具返回全部打日志。我专门写了一个AgentTracer类把这些信息格式化输出 Step 1 [Prompt] 你是一个可以调用工具的智能助手... [Raw Output] Thought: 我需要查询北京天气 Action: queryWeather Action Input: {city: 北京} [Parsed] actionqueryWeather, input{city北京} [Observation] 北京今天晴气温 25 度 Step 2 [Raw Output] Thought: 我已经知道答案了 Final Answer: 北京今天晴天气温 25 度。有了这个 trace任何问题都能快速定位到是哪一环出的错。7. 落地时绕不开的几个工程问题7.1 并发场景下的状态隔离如果你的 Agent 服务要同时处理多个用户请求状态隔离就是必须的。每个请求要有独立的steps列表、独立的 sessionId不能共享任何可变状态。我用的是每次请求 new 一个AgentRunner实例的方式简单粗暴但有效。工具注册表这种无状态的可以共享但要注意线程安全。7.2 成本控制token 消耗的监控Agent 比普通对话费 token因为每一轮都要把历史轨迹重新发一遍。一个 5 步的 Agent 任务token 消耗可能是单轮对话的 10 倍以上。我在 LLM Client 里加了 token 计数每次调用后累加超过阈值就告警。控制成本的手段有几个一是精简 prompt工具描述不要写废话二是及时裁剪上下文别把无关历史一直带着三是能用小模型的地方就用小模型比如输出解析这种任务不需要大模型。7.3 安全边界工具权限与输入校验Agent 安全是个大话题我这里只说最基础的两点。第一工具权限要分级读操作和写操作分开写操作工具要额外确认。第二所有来自模型的参数都要当作不可信输入处理该转义转义该校验校验绝对不能直接拼接到 SQL 或命令里。注意永远不要给 Agent 一个能执行任意代码或任意命令的工具这是最危险的设计。7.4 可观测性日志、指标、追踪生产环境的 Agent 必须有可观测性。日志记录每一轮的完整轨迹指标记录成功率、平均步数、平均耗时、token 消耗追踪用 traceId 把一次请求的所有调用串起来。这些基础设施在排查线上问题时能救命。8. 我在实现过程中踩过的几个坑第一个坑是模型不按格式输出。有次用某个模型它把Thought和Action写在了同一行导致我的按行解析逻辑失效。后来改成用正则匹配字段而不是按行分割才解决。第二个坑是工具参数类型不匹配。模型有时候把数字参数传成字符串比如{count: 5}而不是{count: 5}。我在参数校验里加了自动类型转换字符串能转数字的就转转不了才报错。第三个坑是上下文裁剪把关键信息裁掉了。有次 Agent 在第 3 步需要用到第 1 步的工具返回结果但裁剪策略把第 1 步删了导致模型反复调工具。后来改成裁剪时保留所有工具调用的 Observation只压缩 Thought 部分。第四个坑是超时设置不合理。工具超时设太短正常调用被中断设太长Agent 整体响应慢。最后按工具类型分别设置查询类 10 秒计算类 5 秒外部 API 类 30 秒。第五个坑是错误信息对模型不友好。一开始工具报错直接返回异常堆栈模型看到一堆 Java 类名完全不知道怎么处理。后来改成返回自然语言的错误描述加建议模型的自修复能力明显提升。这几个坑的共同点是它们都不会在简单 demo 里暴露只有真正跑复杂任务、跑多轮、跑并发的时候才会出现。这也是我建议自己实现一遍的原因——框架帮你屏蔽了这些细节但也让你失去了理解和解决这些问题的机会。9. 后续可以继续深挖的方向把最小 Agent 跑通之后有几个方向值得继续做。一是多 Agent 协作让多个 Agent 各司其职通过消息传递协作完成复杂任务。二是工具的动态发现从 OpenAPI 文档自动生成工具定义而不是手写。三是执行轨迹的评估用另一个模型来评判 Agent 的执行过程是否合理自动发现 prompt 和工具描述的问题。四是流式输出让用户能实时看到 Agent 的思考过程体验会好很多。我现在这个版本大概 800 行核心代码去掉注释和空行可能就 500 行左右但把 Agent 的核心机制都覆盖了。代码量不大但每一行都是想清楚才写的。如果你也在做类似的事情建议先从最小闭环开始跑通了再逐步加功能别一上来就追求大而全。
阅读完成 · 觉得有帮助?