ARTICLE · INTELLIGENCE

战地情报 · 详情页

来自尧图项目组的一线实战观察与深度解析

用Java从零实现Agent智能体:架构、工具调用与工程化落地实战

用Java从零实现Agent智能体:架构、工具调用与工程化落地实战

大约半年前,我在技术群里丢了一句“我准备用 Java 开发一个 Agent 智能体”,得到的回复基本都是“认真的吗”“Python 不香吗”之类的声音。我承认,圈子里的主流玩法确实是 Python 那一套——框架多、更新快、随便翻到一段示例代码就能跑起来。但我还是把 lucky_agent 这个项目坚持做了下来,到现在它已经在我们内部跑通了资料检索、工单自动分类、日志异常分析三条业务线。这篇文章不是泛泛地聊概念,而是把我从零用 Java 实现 Agent 智能体的全过程摊开来讲:选型考量、架构拆分、工具链路、记忆管理、并发编排,以及安全与可观测性这些在企业落地时绕不开的工程问题。如果你也在犹豫要不要用 Java 搞 Agent,或者已经有了 Agent 项目但觉得它不够稳、不够可控,这篇应该能给你一些实在的参考。

1. 为什么在Python席卷Agent圈的今天,我仍用Java写了lucky_agent

1.1 选型之前,先想清楚Agent要替谁干什么

先说结论:不是因为我讨厌 Python,也不是因为 Java 比 Python 更“高级”,而是因为我需要把 Agent 放进一个已经存在的企业技术体系里,而不是从零搭一套新体系。

我当时所在团队的技术栈非常统一:Java + Spring Boot,核心业务系统、权限中心、数据服务、监控平台全部在这一侧。如果为了一个 Agent 项目专门引入一套 Python 服务,意味着从部署环境、日志采集、配置中心、权限接入到安全审批全部要多维护一套。这个成本对于一个内部工具项目来说是真实负担。更关键的是,Agent 最终是要干活的,它迟早要调用公司内部的订单、工单、文档、日志系统,而这些系统的接口和鉴权体系都是 Java 的。与其让 Python Agent 通过 HTTP 去绕一圈调 Java 接口,再单独做一层鉴权对接,不如让 Agent 直接活在这套体系里,调用链短一截,权限模型也完全复用。

所以如果你现在也在考虑 Agent 的技术栈,我建议你先别急着抄别人的选型,而是先回答一个问题:这个 Agent 服务的用户是谁?它的上下游系统是谁?如果答案是企业内部那套 Java 体系,那 Java 做 Agent 并不吃亏。

1.2 Java开发Agent的劣势,以及我怎么绕过去

我也不回避问题。Java 做 Agent 确实有几块明显的短板。

第一,LLM 生态 SDK 少。OpenAI 的官方 Java SDK 更新节奏慢,很多新特性要等,Claude 的 Java SDK 也大多是社区维护。我刚开始做的时候翻遍 GitHub,能参考的成熟 Java Agent 框架寥寥无几。第二,社区讨论集中在 Python。LangChain 里那些 Tool calling、Memory 抽象、Agent 循环的实现思路,大量设计参考都是 Python 写的,想用 Java 实现需要自己翻译一遍,还得处理不少隐式依赖。第三,Java 的表达力没有那么“灵动”,写动态的、变长的流程组合会显得啰嗦。

我的绕法比较务实。LLM 调用本质就是 HTTP + JSON,Java 有成熟的 HTTP 客户端和 Jackson,我不依赖某一个封装 SDK 的完整度,直接把核心接口自己封装一层,反而更容易替换大模型供应商。后面我换过一次模型供应商,改起来基本就是换配置和适配器。Agent 的设计模式,包括 ReAct、Plan-and-Execute、工具调用,都是公开思路,不绑定语言。我把这些模式翻译成 Java 的接口和抽象类,代码确实啰嗦一点,但好处是结构清晰,不太容易写出那种“灵机一动”的不可维护代码。至于那些确实只有 Python 生态做得好的能力,比如某些专用的文本处理模型,我单独部署成独立服务,让 lucky_agent 通过 HTTP 去调用它。这不是妥协,是把合适的事交给合适的工具。

1.3 三个项目背景逼着我动手

说实话,lucky_agent 最初是被具体需求推着走的,不是拍脑袋觉得“Agent 很火”就开工。我们内部有三个场景长期占研发人力。

一个是资料检索。新同事查历史方案、合同模板、制度文档,总是在聊天记录和共享盘里翻很久,反反复复问同样的问题。一个是工单分类。客服组每天几百条工单需要人工打标签,每个标签对应不同的责任人,纯机械劳动但不得不做。还有一个是日志异常分析。故障复盘时,工程师要在海量日志里找相关线索,经常花半小时才能拼出时间线。

这三个场景的共同点是:流程相对固定、决策链路不长、文本处理量大。它们不需要 Coding Agent 那种复杂的代码执行沙箱,只需要一个能理解指令、调用内部工具、按流程走几步的 Agent。用 Java 开发,刚好能直接接上内部已有的搜索服务、工单系统和日志平台。这里我也多说一句:很多人一上来就奔着“通用 AI Agent”去设计,什么都想做,结果什么都做不稳。lucky_agent 的定位从一开始就限定成“企业内部工具型 Agent”,它的价值不是写诗画画,而是把重复的、有明确操作路径的活儿接住。想清楚这一点,后面所有的架构决策都有了依据。

2. lucky_agent整体架构:从“大模型调用壳”到“可编排Agent运行时”

2.1 模块划分:Core、Tools、Memory、Server

lucky_agent 的代码仓库一开始其实没有分模块,所有代码堆在一个 Maven 模块里。直到工具数量超过十个、Agent 分支超过三条之后,我才意识到必须把边界划清楚。当前的结构是这样:

  • lucky-agent-core:核心运行时,包含 Agent 主循环、LLM 适配器、工具调用链路、记忆接口、任务编排引擎。
  • lucky-agent-tools:内置工具实现,包括文档检索、工单查询、日志搜索、HTTP 请求、时间计算等。
  • lucky-agent-memory:记忆模块的具体实现,包括短期上下文管理、向量存储、结构化记忆。
  • lucky-agent-server:Spring Boot 接入层,提供 REST API、WebSocket 会话管理、鉴权过滤、审计日志。

这个划分的核心逻辑是:core 不依赖任何具体的大模型厂商,也不依赖 Spring,尽量保持轻量;tools 和 memory 依赖 core 的接口做实现;server 负责把一切能力暴露给外部。这样做的直接好处是,我可以写一个简单的 main 方法在命令行里测试 core,不用启动整个 Spring Boot。调试成本和启动时间都降了一个量级。当时我排查过一个挺隐蔽的问题,怀疑是工具调用链路里的缓存逻辑出了问题,没有 core 的独立运行能力,光靠重启服务复现得花不少时间。

2.2 Agent主循环:LLM决策与工具执行的闭环

Agent 之所以是 Agent,而不是一个普通的大模型问答接口,核心在于它有一个“决策—行动—观察”的闭环。lucky_agent 的 core 里有一个 AgentExecutor,职责就是循环执行这四步:

  1. 把系统提示词、历史对话、当前用户请求、可调用的工具描述组装成一次 LLM 请求。
  2. LLM 返回结果。如果结果是普通文本,直接结束,把答案返回给调用方。
  3. 如果 LLM 返回的是工具调用请求(tool_call),校验参数后执行对应工具,得到观察结果。
  4. 把工具执行结果作为新的消息加入上下文,回到第 1 步,直到 LLM 给出最终答案或达到最大轮数。

用 Java 写出来大致是这样的:

AgentResult run(String userInput) { List<Message> messages = historyService.loadSession(sessionId); messages.add(new UserMessage(userInput)); for (int i = 0; i < maxIterations; i++) { ChatResponse resp = llmAdapter.chat(buildRequest(messages, toolRegistry.getToolSchemas())); if (resp.hasToolCalls()) { for (ToolCall call : resp.getToolCalls()) { Object result = toolRegistry.execute(call.getToolName(), call.getArguments()); messages.add(new ToolResultMessage(call.getToolCallId(), serialize(result))); } } else { return AgentResult.finish(resp.getContent(), i + 1); } } return AgentResult.timeout(maxIterations); }

这个循环有一个必须强调的设计:工具执行和 LLM 调用之间是严格串行的。因为后一次 LLM 调用必须带着前一次工具执行的结果才能做出下一个决策,这个依赖关系决定了不能简单地把整个循环并行化。我看过一些框架试图把多个工具调用同时执行,用异步回调拼结果,确实能在单轮内提一点速度,但整体的复杂度和出错概率会明显上升。我的建议是:同一轮内多个相互独立的工具调用可以并行,但并行只发生在单次循环的内部,不要在 Agent 循环层面玩花活。

2.3 把“编排”从Agent循环中独立出来

lucky_agent 做得相对重的一个设计,是把任务编排从 Agent 主循环里拆了出去。主循环解决的是“一次对话中的多轮工具调用”,编排解决的是“一个复杂任务需要多个 Agent 或多次工具调用按顺序执行”。

拿工单自动分类这个场景来说:第一步,调用检索工具查出工单历史相似案例;第二步,把案例摘要和工单文本一起送给一个专门的分类 Agent;第三步,分类完成后调用工单系统接口回填标签;第四步,如果置信度低于某个阈值,调用通知工具转人工。这四步之间存在明确的数据依赖,但每一步内部可能又有自己的多轮工具调用。我为此写了一个 WorkflowEngine,用 JSON 描述步骤,每个步骤可以指向一个 Agent 节点、工具节点或条件节点,执行引擎负责按依赖关系推进。

{ "steps": [ { "id": "search", "type": "tool", "tool": "search_documents", "next": "classify" }, { "id": "classify", "type": "agent", "agent": "ticket_classifier", "next": "update_label" }, { "id": "update_label", "type": "tool", "tool": "update_issue_label", "condition": "confidence > 0.7", "next": "done" }, { "id": "notify", "type": "tool", "tool": "notify_manual", "condition": "confidence <= 0.7", "next": "done" } ] }

配置驱动编排是我觉得 Agent 项目里特别值得投入的一件事,因为业务流程一定会变,我不希望每调整一个分支都要改 Java 代码并重新发布。把流程抽象成配置之后,业务人员和接近业务的技术同事也能看懂任务是怎么跑的。

3. 工具注册与函数调用:让Agent真正“动手干活”

3.1 用注解把普通Java方法变成Agent工具

工具是 Agent 的“手”。lucky_agent 的工具注册中心设计得比较轻量,核心就是一个注解加反射扫描。任何 Java Bean 方法,只要标上 @AgentTool 并提供名称和描述,启动时就会自动注册到工具注册中心。这个思路借鉴了 Spring 的注解风格,但实现没有绑定 Spring,core 模块自己维护了一个扫描器。

@Component public class IssueTool { @AgentTool(name = "query_issue", description = "根据工单号查询工单详情") public Map<String, Object> queryIssue(@ToolParam(description = "工单号") String issueId) { return issueService.queryDetail(issueId); } @AgentTool(name = "update_issue_label", description = "为工单更新分类标签") public void updateLabel(@ToolParam(description = "工单号") String issueId, @ToolParam(description = "目标标签") String label) { issueService.updateLabel(issueId, label); } }

注册之后,核心要解决的一个问题是:如何让 LLM 知道有哪些工具可用、参数怎么填。我选择的方式是生成 JSON Schema。启动时,注册中心利用反射提取方法名、参数名、@ToolParam 里的描述,组装成一个 OpenAI 风格的 function schema 数组,在每次对话时放进请求消息里。这里有个小教训:一开始我嫌生成 schema 麻烦,想用 Jackson 手工拼接,结果发现手写描述很容易和代码不一致,工具改个参数名,schema 忘了更新,模型就开始报错。后来改成注解里的描述作为唯一事实源,并加了一个单元测试,确保所有注册工具都能成功生成 schema,这个问题就彻底解决了。

3.2 从“LLM说调就调”到“参数校验与错误回填”

工具调用链路最容易被忽略的环节是参数校验和错误处理。LLM 生成参数这件事本身就是概率性的,哪怕你给了 JSON Schema,它也可能多传字段、传错类型、甚至把枚举值写成自由文本。

我在 toolRegistry.execute 里加了三道防线。第一道是 JSON Schema 校验,用 json-schema-validator 库,开启严格模式,类型不对直接拒绝。第二道是业务校验,比如查询工单号时校验格式,更新状态时校验状态机是否允许流转。第三道是执行兜底,任何工具执行过程中抛出的异常,不允许直接传给 LLM,而是包装成一个结构化的错误结果,告诉 LLM“工具执行失败,原因是 XXX,请基于这个结果重新决策”。

这个环节我踩过一个非常实际的坑:一开始工具异常时直接把 Java 异常堆栈抛给 LLM,结果模型看到一堆 “NullPointerException at line 43” 就开始胡说八道,甚至反复调用同一个失败工具,陷入死循环。后来我把错误处理改成“面向 LLM 的错误信息”,对模型友好很多。比如:

{ "status": "error", "error_code": "ISSUE_NOT_FOUND", "message": "工单号 ISS-20240601-001 不存在,请向用户确认单号是否正确", "suggestion": "可以询问用户是否需要查询其最近创建的工单" }

这一处小小的改动,让工具调用的成功率肉眼可见地涨了十几个百分点。我现在写任何工具的实现时,都会额外写一个“给模型看的错误信息”,这已经成了团队内部约定。

3.3 危险工具的权限与审批预留

工具一旦能操作业务系统,就不能让每个 Agent、每个用户都能随便调。我在工具注册中心里给每个工具增加了一个 access 属性,标明这个工具需要的权限位。工具执行前,会从当前会话上下文里取用户身份,判断该用户是否具有这个权限位。没有权限的调用会直接返回“无权限”结果,并记录审计日志。

设计这一层的时候,我还在考虑另一个问题:未来如果要支持高危险操作的二次确认,只需要在工具调用链里加一个 approval 环节,也就是“Agent 发起请求,人工审批通过后工具才真正执行”。这个思路在工单回写、删除操作、批量修改这类场景里尤其重要。我不建议把这种安全检查交给 LLM 去自行判断,因为模型对权限的理解是不稳定的,宁可写死静态规则,也不要指望模型的自觉。

为了让大家更直观地理解不同工具的权限层级,我把工具大致分成这么几类:

工具类别示例权限策略
查询类文档检索、工单查询、日志搜索只读权限,按数据范围过滤
写入类更新标签、状态流转、备注追加写权限,记录操作人
高风险类批量删除、配置修改、发送通知写权限 + 人工审批预留
外部调用类HTTP 请求、调用第三方接口白名单域名 + 权限位

4. 记忆管理:短期上下文与长期记忆的实现取舍

4.1 短期记忆:先做对Token窗口的裁剪,再谈别的

Agent 有记忆才能连续对话,但“记忆”在工程上从来不是概念问题,而是资源问题。LLM 的上下文窗口是有限的,把整个会话历史原封不动塞给模型,既不经济,也可能导致模型在超长上下文里丢失关键信息。

lucky_agent 的短期记忆分为两层。第一层是窗口裁剪,按消息条数和 Token 数双层限制。我当时给默认配置设的是最大消息数 20 条、最大 Token 数 8000 左右,具体按所用模型的上下文窗口的 30% 来控。超过阈值就把最早的消息丢弃。第二层是摘要压缩,当窗口内消息太多时,调用一次 LLM 把老旧对话压缩成摘要,然后保留“摘要 + 最近 10 条原始消息”。这个逻辑有点像新闻编辑部处理稿件:重要内容被摘要保留,新消息完整展示,更老的内容归档进资料室,而不是每天重新铺满桌面。

这里有个对成本非常敏感的细节:摘要压缩不能每轮对话都做,否则 LLM 调用次数会翻倍。我的策略是只有当消息总量超过最大 Token 阈值的 70% 时才触发压缩,并且压缩结果打上 summary 标记,参与后续的 Token 计算。总之短期记忆的目标不是“记得更多”,而是“在有限预算里保留最有价值的上下文”。

4.2 长期记忆:向量检索+结构化事实,两条腿走路

短期记忆只解决“最近聊了什么”,长期记忆解决“这个用户上次说过什么偏好”“这个项目组有哪些惯例”。lucky_agent 的长期记忆用两个存储来组合:一个是向量检索,存对话片段、文档片段、用户偏好,按语义匹配;另一个是结构化存储,存用户画像、任务状态、历史结论,便于精确查询。

向量库的选择上,我没有引入重量级的全文检索扩展,而是直接用上了团队已有的 Milvus 集群,通过 Java SDK 操作。如果你的团队没有现成的向量数据库,我推荐看看 pgvector 配合 Postgres 的方案,一条 SQL 就能同时查业务数据和向量,对小团队来说非常务实。下面是向量查询的一个示意:

List<MemoryItem> findRelevantMemory(String sessionId, String query, int topK) { float[] queryEmbedding = embeddingClient.embed(query); List<Float> queryVector = Arrays.stream(queryEmbedding).boxed().toList(); return memoryStore.searchSimilar( sessionId, queryVector, topK, "created_at > now() - interval '90 days'" ); }

这里还有一个容易被忽略的计算点:向量检索返回的 topK 结果不能全部塞进上下文,因为每一条都需要占用 Token。我的策略是先把 topK 设成 20 条,过滤掉相似度低于 0.75 的,再按“时间优先 + 相似度优先”的加权排序取前 5 条进入上下文。否则模型很容易被大量表面相似但实际无关的旧记忆带偏。

4.3 记忆写入的时机和遗忘策略

记忆不是越多越好。我见过一些 Agent 项目,长期记忆表无限膨胀,结果每次检索都查到一堆过期内容,反而干扰模型判断。lucky_agent 对记忆写入做了三个限制。

第一个是写入时机。只有在任务完成、或者用户显式表达了偏好时才写入记忆,不在对话过程中每条消息都写。第二个是写入前做去重和打分。如果新信息和已有记忆高度重复,就不写。第三个是读取时带时间衰减。三个月前的记忆权重会降低,除非用户在近期对话里重新提到它。这套东西在产品上其实就是“遗忘机制”。没有遗忘机制的 Agent,就像一辈子不丢旧东西的人,家里迟早堆满杂物,翻找东西反而更困难。

这个机制上线的时候,我们内部有过争议,有人担心“忘记”会损失信息。但实际跑下来,因为响应质量的提升非常明显,大家很快接受了这个设计。

5. 让Agent扛住并发与复杂工作流:任务编排与异步调度

5.1 单Agent串行循环与多Agent并发边界

聊完记忆,马上要面对的问题就是“Agent 到底怎么扛并发”。很多人的第一反应是“Agent 调用大模型是网络 IO,只要起线程池就行”。这其实把事情想简单了。一次 Agent 任务是一个有状态的多轮循环,每一轮都要和 LLM 交互,不是一次 HTTP 请求那么简单。直接裸起线程当然可以,但如果没有控制并发边界,一旦某个模型供应商的接口限流,整个服务的线程都会卡在等待 IO 上,很快把连接池打满,最终表现为接口超时雪崩。

lucky_agent 的并发策略分两层。第一层是任务级并发:每个会话是一个独立 Agent 任务,任务之间天然无关,我用线程池承载,如果 JDK 版本允许,更推荐用 Java 21 的虚拟线程。虚拟线程在这个场景下非常合适,因为 Agent 轮询里大量是阻塞式 HTTP 调用,虚拟线程能以极低成本支撑大量并发会话。我们压测过,单机 2000 个并发会话,每个会话平均 3 轮 LLM 调用,每轮调用 1.5 秒,整体吞吐还是很可观的。第二层是会话内部的流水线并发:一个任务内部如果某个步骤要同时查询多个数据源,就用 CompletableFuture 做局部并行,然后等待全部返回再继续。

5.2 任务编排引擎:DAG与失败重试

WorkflowEngine 是 lucky_agent 里最复杂也最值得讲的一块逻辑。它把编排定义抽象成节点和依赖关系,节点类型有四种:agent 节点(执行一个子 Agent)、tool 节点(执行一个工具)、condition 节点(根据上一步结果决定走哪个分支)、delay 节点(定时或延迟执行)。节点之间通过“下一跳”列表链接,引擎用拓扑排序判断执行顺序。

执行引擎的容错策略必须提前设计好,否则一上线就会出事。每个节点都有超时时间和重试次数。超时用 Future.get(timeout) 实现,重试时默认指数退避,避免在故障恢复瞬间集中重试造成二次冲击。一个任务是继续往下走还是整体标记为失败,取决于该节点是否配置了“失败容忍”。我的经验是:对于查询类节点,容忍失败并返回空结果通常不影响主流程;对于写入类节点,绝不能自动重试,因为重复写入可能造成脏数据。这个原则我写进了代码注释里,也写进了团队技术文档里。

5.3 数据一致性:Agent操作业务系统不是“只读游戏”

Agent 一旦开始写数据,就要面对分布式系统里最头疼的一致性问题。lucky_agent 在多数场景下是“用户代理”的角色,它调用的是各个业务系统既有接口,而这些接口原本就有自己的事务边界。Agent 层不应该试图包一个大事务。我的做法是“尽力而为 + 补偿记录”:

  • 写操作尽量走业务系统自己的接口,由业务系统保证单接口的原子性;
  • Agent 的编排引擎记录每一步已执行的操作,形成操作日志;
  • 如果某个任务后续步骤失败需要回滚,引擎尝试调用每个已执行节点上注册的补偿动作,比如更新标签前先记录旧标签,补偿动作就是恢复旧标签;
  • 无法补偿的操作,比如发送通知,则在审计日志里标记“人工确认”。

这个方案并不完美,但很现实。造一个复杂的分布式事务引擎不是 Agent 框架该做的事,不如承认分布式事务不能靠框架彻底解决,重要的是把不可控的动作记录下来,让问题可见、可回溯。

5.4 行级权限在Agent中的落地

热词里提到“行级权限 java”,这其实正是 Agent 在企业场景落地最容易翻车的地方。一个查询工具如果只校验“能调用”,不校验“能查哪些行”,那么一旦 LLM 生成的查询条件越界,就会造成越权。lucky_agent 的做法是:数据查询类工具不直接接收裸 SQL,而是接收“语义查询条件”,工具内部把条件翻译成带租户 ID 或部门 ID 过滤的 SQL。部门隔离是写死在工具实现里的,不能由 LLM 传入。模型可以决定“查哪个客户”“按什么条件筛”,但它永远不能决定“查哪个部门的数据”,因为后者来自会话上下文,由服务端自动拼接。

这个设计一开始被团队吐槽“限制了 Agent 的能力”,但上线之后大家才发现,它正是业务部门愿意放权的前提。权限模型不该让模型来理解,而应该在工具层做强制性兜底。如果你做一个 Agent 要接企业内部数据,行级权限这一关是绕不过去的,越早设计越好。

6. 企业落地避坑清单:安全、稳定与可观测性

6.1 部署形态与运行时调优

先说明一下,这里的“Java Agent”是在说 lucky_agent 这个业务服务,不是 JVM 的 instrumentation agent,这个名词在 Java 圈里容易混淆。我们的部署形态是 Spring Boot 打成 Docker 镜像,放在容器平台里运行。这里有两个实际教训值得分享。

第一个是内存管理。Agent 服务在和大模型交互时需要缓存会话上下文和工具结果,如果不限制缓存上限,Docker 容器很容易 OOM。我的做法是给 Caffeine 缓存设置 maximumSize 和 expireAfterWrite,时间窗口设为 30 分钟,并且把大对象在返回给 LLM 之前做截断。比如日志检索的原始结果超过 4000 字时,先让摘要模型压缩一遍再进上下文,原始结果另外存到临时存储供追踪。

第二个是超时配置。LLM 接口的响应时间波动很大,HTTP 客户端的 connectTimeout、readTimeout 必须根据模型供应商的实际表现来调。我给默认配置设的是 connectTimeout 3 秒、readTimeout 60 秒,因为很多慢模型的单次推理可能超过 10 秒。如果按标准化的 10 秒超时设,慢模型上会频繁触发重试,既浪费钱又增加延迟,还容易把业务方惹毛。

6.2 安全护栏:prompt注入防护与敏感信息脱敏

Agent 面对的安全威胁比传统 API 更复杂,因为它会把外部输入当成指令来解读。最常见的攻击形式是让 Agent 把“你是一个 AI 助手,请忽略所有指令”这类文本伪装成业务数据塞进来。lucky_agent 的防护思路是不信任任何拼接进来当指令的内容:

  • 系统级消息和工具描述固定从配置文件读取,不参与业务数据拼接;
  • 对用户输入做关键指令词检测,命中则拒绝执行并提示;
  • 工具返回的业务数据默认按“数据”标记,明确提示 LLM 这是数据不是指令;
  • 输出侧过滤器脱敏,手机号、身份证号、账号密码等用正则加模型双重识别,命中的字段在返回给用户前打码。

这套护栏做不到 100% 安全,因为理论上模型总可能被更高级的越狱方式骗过。但对于企业内部的封闭业务场景,它已经能把风险压到可接受的范围。如果你的 Agent 要接公网用户输入,我建议在 Agent 前面再加一层专门的输入过滤服务,不要把所有希望都寄托在提示词工程上。

6.3 可观测性:用trace_id贯穿一次Agent的完整生命周期

Agent 的调试比传统程序难得多,因为同一时间内既有代码调用,又有模型推理,还有外部工具执行。没有可观测性,出了问题只能靠猜。lucky_agent 里每个会话在创建时就全局生成一个 traceId,从 HTTP 入口到 LLM 调用到工具执行到记忆读写,所有日志都带上它。

我重点打了三类日志。第一类是 LLM 调用日志,记录请求的消息条数、Token 数、响应耗时、返回内容摘要。第二类是工具调用日志,记录工具名、入参、出参长度、执行耗时、是否成功。第三类是编排引擎日志,记录每一步节点的开始、结束、失败重试。这三类日志拼起来,就能完整还原一次 Agent 任务的轨迹。再加上把 traceId 透传到下游业务系统,排障效率提升非常明显。上线后两周内,我们就用这套日志找出了三个隐藏问题:记忆压缩误删关键信息、某个工具接口偶发超时导致重试风暴、以及一个大模型的参数配置在不同会话间串了。

下面是一个简化的日志结构:

{ "trace_id": "a1b2c3d4e5", "session_id": "session_20240601_001", "type": "tool_call", "tool": "query_issue", "args": { "issueId": "ISS-20240601-001" }, "result_length": 532, "success": true, "duration_ms": 212 }

6.4 测试策略:Mock LLM是唯一的路

测试 Agent 的难点在于 LLM 的输出有随机性。直接对真实模型做用例断言,等于在做概率测试。lucky_agent 的测试分三层。

第一层是单元测试,Mock 掉 LLM 适配器,让它返回固定的 tool_call 请求或固定文本,验证工具注册、参数校验、错误处理是否按预期执行。第二层是场景测试,用录制的“LLM 响应序列”回放工具调用流,验证编排引擎的节点流转是否符合预期。第三层是灰盒测试,连真实模型但在测试环境跑少量样本,由人来检查结果。如果没有第一层和第二层,靠真实模型调 API 测出来的问题,你永远分不清是代码 bug 还是模型抽风。

我后来还专门做了一个小的录制工具:跑一次真实任务,把 LLM 的请求和响应全部存下来,变成回放用例。这个工具帮了大忙,每次改动核心逻辑,都能快速跑一遍历史场景,避免“修好一个 bug 引出三个新问题”的恶性循环。

最后分享一点个人体会。做了 lucky_agent 之后,我最大的感受是:Agent 项目里最容易被低估的其实是工程问题,不是模型问题。模型的能力就在那里,各家差距没有想象中大,但谁能把工具、记忆、并发、权限、可观测性这些环节做扎实,谁的项目才能真正被业务部门用起来。Java 在这个赛道上确实没有 Python 热闹,但它是企业级工程化的老手,类型系统、并发模型、成熟生态这些能力,在 Agent 进入生产环境之后会变成实打实的优势。

如果你也想动手写一个类似的 Agent,我会建议把范围缩小,先挑一个非常具体的业务流程跑通闭环,再逐步加工具、加记忆、加并发。lucky_agent 下一步我计划做两件事:一是把记忆模块的摘要策略从固定阈值改成动态判断,二是在编排引擎里支持人工审批节点,让高风险操作走“Agent 发起 + 人工确认”的流程。这个方向之后的实战结果,等我跑完一轮再回来分享。

RELATED READING

延伸阅读

更多一线实战笔记与深度复盘,助您持续精进