先给结论:与其满世界搜“Deepseek Harness 安装教程”,不如自己动手用 Java 把它复刻一遍。我说的复刻,不是把 Python 源码翻译成 Java 语法,而是把 Harness 背后那套围绕大模型编排的架构逻辑吃透,再用 Java 生态里最合适的组件一比一还原。这篇文章不聊安装和使用,只聊怎么设计、怎么建模、怎么把 Agent 编排这种偏动态的流程,用 DDD 的方式稳稳落地。
如果你是一个 Java 后端开发者,平时写微服务写得比较多,也想接触 LLM 应用开发,但又不想一上来就啃 Python 那套 AI 生态,那这篇文章应该能给你一条比较清晰的路径。我会把领域模型怎么划分、聚合根怎么设计、MCP 工具调用循环怎么实现、上下文状态机怎么推进、token 和耗时统计怎么做,逐个拆开讲,每一段都附带我在实际开发中踩过的坑。
1. Deepseek Harness 到底是什么:先拆解再复刻
1.1 从热词里的困惑说起
从搜索引擎的热词能看到,大部分人对 Deepseek Harness 的认知停留在“这是什么”、“怎么安装”、“和 codex harness 有什么区别”这个层面。这很正常,因为这个项目本身带有比较强的工具属性,大家第一反应是拿过来用。但如果你只停留在“会用”,那它对你来说只是一个黑盒。真正有价值的是看它怎么被构造出来的:它本质上是一个大模型应用的编排框架,连接了模型、工具和外部数据源,然后通过对话循环把任务一步步推进下去。
所以复刻之前,我要先把它拆成一个后端工程师能看懂的系统视图,而不是一个 AI 产品的使用视图。
1.2 Harness 的系统构成
从一个后端系统的角度看,Harness 做的事情其实可以归纳成四件事:
一是模型接入层。负责跟大模型 API 通信,组织 messages、处理流式输出、管理上下文长度。这块和传统后端调用第三方 HTTP 接口没有本质区别,区别在于协议更动态——你需要维护一个不断增长的 messages 数组,每次请求都会把之前的对话记录完整带上。
二是工具调用循环。大模型本身不会执行操作,它只会输出一个工具调用意图,比如“我想查询 weather.forecast,参数是 {city: 北京}”。Harness 要负责解析这个意图,路由到具体的工具实现,拿到结果,再作为一条新的消息送回给模型。这个循环会一直持续,直到模型不再要求调用工具为止。
三是上下文管理。模型有上下文窗口限制,你不能无限堆 messages。Harness 需要做截断、压缩、摘要,把最重要的信息保留下来。这一块是整个系统里最容易被低估的部分,后面我会专门开一节讲。
四是会话编排。一次完整任务不是一遍就完成的,可能需要模型先拆解计划,再逐步执行,中间还可能要求用户补充信息。Harness 要把这些状态流转管起来,就像订单状态机一样,只是转移条件不是确定的,而是由模型输出决定的。
所以,复刻 Harness = 复刻一个支持工具调用循环的 Agent Runtime。这句话想清楚之后,后面的技术选型就顺理成章了。
1.3 为什么用 DDD 来做这件事
热词里有不少人在搜 DDD 和事件风暴,说明大家对这个话题确实有兴趣。但说实话,很多 Java 项目里的 DDD 是伪 DDD——建一堆 Entity、ValueObject、DomainService 就完了,实际业务逻辑还是全塞在 Service 里。DDD 的价值不在于代码分层,而在于它能逼你把领域知识搞清楚。
Agent 编排系统恰恰是一个适合 DDD 的场景,原因是它的核心复杂度不在技术而在状态。工具调用循环、会话推进、上下文窗口管理,这些行为背后是一个个很明确的领域概念,比如:
- 模型不是 Entity,它是外部系统,领域里只有 ModelPort;
- 工具调用是一个 Process,它有发起、执行、完成、失败这几个状态;
- 上下文不是简单的 List,它有自己的容量策略、淘汰策略,是一个典型的 Aggregate;
- 会话本身是一个状态机,承载整个编排流程的推进。
如果不用 DDD,直接写一个AgentOrchestrator类,把循环和状态都写在里面,短时间内能跑,但等到你要加多模型支持、加人机协同、加多工具链的时候,这个类就会变成几百行起步的上帝类,谁都不敢动。
DDD 在这里的价值,就是先把问题空间切干净,再动手写代码。这样代码的骨架是跟着领域走的,不是跟着框架走的。
2. 技术选型:为什么这一套组合能扛住 Agent 编排
2.1 技术栈清单与选型理由
复刻项目我选的技术栈是这样的:
| 组件 | 用途 | 选型理由 |
|---|---|---|
| JDK 21 | 基础运行环境 | 虚拟线程处理 Agent 编排中的回调阻塞场景,比 WebFlux 更符合直觉 |
| Spring Boot 3.3 + Spring AI 1.0.0-M6 | 应用框架 + 模型接入抽象 | Spring AI 统一了多模型接入的接口,避免自己手写 HTTP 协议层 |
| PostgreSQL + pgvector | 会话与上下文持久化 | 向量字段以后做语义路由和记忆召回要用 |
| MCP Java SDK 0.10.0 | 工具协议接入 | 社区正在往 MCP 标准化靠拢,直接用协议层而不是自己定义 JSON Schema 格式 |
| Immutables | 不可变对象生成 | 领域模型的 ValueObject 数量大,手写 equals/hashCode 太容易错 |
这里面两个最关键的决策,一个是 JDK 21 的虚拟线程,一个是 Spring AI 的 Tool Callback 机制。我分别说一下。
2.2 虚拟线程:Agent 循环的最佳拍档
Agent 编排循环里有一个非常烦人的点:模型在流式输出工具调用参数的时候,你要等它把 JSON 流攒完才能开始执行工具。也就是说,你的执行线程会在某个 IO 等待上卡住。如果按传统思路搞线程池,线程数设少了并发上不去,设多了又吃内存。而 JDK 21 的虚拟线程可以把这种阻塞变成几乎零成本——阻塞一个虚拟线程,底层只是让出 carrier 线程,代价可以忽略不计。
所以我在设计执行引擎时采用了一个很大胆的姿势:直接用一个虚拟线程跑整个 session 的自动化推进循环。一次任务一个线程,哪个工具的响应回调在等着,就让它等,完全不心疼。这在传统线程池模型里是不敢想的,因为线程是稀缺资源,但在虚拟线程模型里这就是默认玩法。
2.3 Spring AI 的抽象 vs 自己封装 HTTP 调用
很多从 Python 生态转过来的人会用 OpenFeign 或者 RestClient 直接调模型 HTTP 接口,这种方案的问题在于:你需要自己维护协议细节,包括 stream 解析、tool call 格式、message 历史截断算法。而 Spring AI 已经在做这层抽象,虽然它的 API 还在演进,但方向是对的——ChatClient接口屏蔽了不同模型供应商的差异,ToolCallback抽象屏蔽了模型侧工具函数定义的差异。
不过我要强调一个原则:Spring AI 只用来做模型接入这薄薄一层,绝对不要让 Spring AI 的对象泄漏到领域层。领域层里只有ChatPort接口,实现类里面才允许出现ChatClient这样的框架类型。这就是 DDD 里的依赖倒置,不是代码洁癖,是为了以后换模型供应商的时候不碰业务代码。
2.4 持久化选型:PostgreSQL 就够了
Agent 编排系统要不要引入专门的向量数据库?我的答案是先用 PostgreSQL 加 pgvector。原因是上下文召回这种场景,数据量在没有海量用户之前,根本不需要专门的向量库。PostgreSQL 一套顶住业务数据加向量数据,运维成本最低。等你能做到千万级 session 了,再考虑抽出来也来得及。
3. 开始动手写代码之前,先把领域模型画清楚
3.1 限界上下文划分
我按照 DDD 的标准流程,先做了一次轻量的事件风暴,把系统切成五个限界上下文:
| 限界上下文 | 核心职责 | 关键领域对象 |
|---|---|---|
| ConversationContext | 管理 Message 集合、上下文窗口、Token 预算 | Conversation, Message, WindowSizePolicy |
| ToolInvocationContext | 管理工具定义、参数 Schema、执行状态 | ToolDefinition, ToolInvocation, InvocationState |
| AgentRuntimeContext | 编排 Agent 循环、驱动状态流转 | AgentSession, AgentAction, OrchestrationEngine |
| ModelAccessContext | 对接外部模型、流式解析 | ChatPort, ModelRouter |
| ObservabilityContext | 打点、记录 token 消耗、耗时分布 | TokenUsage, TraceSpan, ToolMetrics |
先别急着写代码,对照你自己的项目想想:是不是经常把 Message 和 ToolDefinition 硬塞到同一个 Entity 里,导致谁都依赖谁?这两个上下文切开的动作,是整个项目后面最值钱的一步。
3.2 上下文映射:共享内核还是防腐层?
每个限界上下文之间的协作方式,我给出了三层设计:
- ConversationContext 对外暴露
AppendMessage和WindowCompact接口,其他上下文不直接操作 Message 表; - ToolInvocationContext 对外暴露一个
ToolRegistry只读视图,谁要执行工具必须通过 ApplicationService,不能直接 new 一个 Tool; - ModelAccessContext 对上是防腐层,它内部的 Spring AI 依赖不会穿透到其他上下文。
你只需要抓住一句话:边界之间只能用接口通信,接口参数必须是值对象或者普通 DTO,绝不允许把对方领域里的实体传进来。这句话做到了,你的 DDD 至少及格了。
3.3 事件风暴产出的关键事件流
事件风暴梳理出来最核心的事件流是这样一条链:
UserPromptSubmitted -> AgentPlanProposed -> ToolInvocationRequested -> ToolExecuted -> ToolResultAccepted -> AgentTurnCompleted(循环回到 ToolInvocationRequested 或进入 ModelResponseProduced)这条事件链是整个编排系统的命脉。你写代码之前先把这条链想清楚,写的时候就会异常顺。尤其是你后面想加人机协同、加失败重试策略,事件驱动设计可以让你在不改变主干流程的情况下扩展。
4. 核心聚合设计:Conversation、ToolInvocation 与 AgentSession
4.1 Conversation 聚合:不要裸放 List
很多 Agent 框架里,对话历史就是List<Message>,用的时候直接全量塞给模型。一开始能用,但等你一个会话跑到几十轮之后,Token 账单会让你肉疼。我把 Conversation 设计成了一个自管理上下文窗口的聚合根:
@Aggregate public class Conversation { private final ConversationId id; private final List<Message> messages; private final TokenBudget budget; public AppendResult append(Message message) { messages.add(message); int currentTokens = TokenEstimator.estimate(messages); if (currentTokens > budget.limit()) { return compact(); } return AppendResult.simple(message); } private AppendResult compact() { List<Message> retained = contextStrategy.selectRetained(messages, budget); messages.clear(); messages.addAll(retained); return AppendResult.compacted(retained); } }你从这段代码能看出,Message 添加不是一个简单 add,它会触发窗口管理策略。这个策略我后面会单独讲,但你先要有这个意识:上下文它不是一个可以无限膨胀的数组,它是有边界的资源。
为了性能,底层存储结构我用了一个滑动窗口队列 + 摘要堆积区的复合结构。窗口内的消息保持完整,窗口外但还没被丢弃的消息压缩成 summary 消息。这样模型既能看到历史脉络,又不会被原始全文撑爆。
4.2 ToolInvocation 的状态机设计
工具调用不能只是一个方法,它必须是一个有状态的过程。因为在实际场景里,一个工具调用需要经历发起、参数校验、执行、结果回填、失败重试这几个环节:
public enum ToolInvocationState { REQUESTED, ARGUMENTS_VALIDATED, EXECUTING, SUCCEEDED, FAILED, RETRY_WAIT }这个状态机是整个工具调用流程的核心。我把它设计成独立的聚合,每个 ToolInvocation 都有自己的生命周期,而不是临时拼一个execute()方法。好处是:你可以单独对失败的调用做补偿,可以对超时的调用做重试策略,而不影响其他调用。
这里我强烈建议在 State 转移记录上打一个TransitionRecord,每一步变更带上时间戳和触发原因。这不是过度设计,排障的时候你就会知道这有多香——你能精确知道哪个工具在哪个环节卡了 5 秒。
4.3 AgentSession:任务级的状态编排
AgentSession 聚合承载的是整个任务的生命周期。我给它设计了四个顶层状态:
| 状态 | 含义 | 关键流转条件 |
|---|---|---|
| PLANNING | 正在生成执行计划 | 模型输出 action 对象 |
| EXECUTING | 正在循环调用工具 | 逐个执行工具并回填结果 |
| WAITING_USER | 需要用户补充信息 | 模型提出澄清问题 |
| COMPLETED | 任务结束 | 模型输出最终答案且无工具调用意图 |
WAITING_USER 这个状态很重要,因为现实场景里模型不是全知的,它需要向用户确认参数。没有这个状态的 Agent 就是个只能处理固定任务流程的玩具。这个状态我专门做了事件阻塞与唤醒机制:session 进入 WAITING_USER 时,虚拟线程挂起;用户回复后通过wakeUp()重新推进入口。
我这里强调一下:状态机不是画完 UML 就完事了,每个转移必须对应代码里真实的事件发布。你会发现事件风暴产出的每条事件,最终都会落到某个状态的转移判定里。
5. 事件编排引擎实现:从消息驱动到工具调用循环
5.1 整体流程拆解
编排引擎是整个系统的中枢,它的职责不是实现业务逻辑,而是像一个导演一样调度各个领域服务。核心流程是:
- 接收用户输入,追加到 Conversation 聚合;
- 调用 ModelAccessContext 的 ChatPort 发送完整上下文;
- 分析模型返回结果:
- 如果没有 toolCall,直接输出最终答案,流程结束;
- 如果有 toolCall,进入工具调用流程;
- 工具调用流程:注册 ToolInvocation 聚合,解析参数,路由到具体工具实现;
- 工具执行结果回填为一条 tool 消息,追加到 Conversation;
- 回到步骤 2,把这个扩展后的上下文再发给模型。
这个循环你可能已经看过很多次了,但真正实现好,细节全在边界处理上。
5.2 Spring AI ToolCallback 的适配层
Spring AI 的ToolCallback接口设计得很干净,核心是call(String toolName, String toolInput)。我用适配器模式把它接进领域:
@Component public class McpToolCallbackAdapter implements ToolCallback { private final ToolRegistry toolRegistry; private final JsonSchemaValidator validator; @Override public String call(String toolName, String toolInput) { ToolDefinition def = toolRegistry.getByName(toolName); ValidationResult result = validator.validate(toolInput, def.inputSchema()); if (!result.isValid()) { return buildValidationError(result); } ToolInvocation inv = ToolInvocation.start(def, toolInput); return toolInvocationService.execute(inv); } }这个适配器的存在,让领域层完全不感知 Spring AI 的存在。它的核心逻辑分三段:查注册表拿工具定义、做参数校验、创建 ToolInvocation 聚合并执行。参数校验必须前置,因为模型输出的 JSON 经常不符合工具定义的 schema,如果不过校验直接执行,工具内部还要写一堆防御代码,脏数据还会污染状态机。
5.3 虚拟线程跑会话:一次会话一个执行单元
我用一个SessionRunner封装了编排循环的入口:
public class SessionRunner { public void runAsync(AgentSession session) { Thread.ofVirtual() .name("session-" + session.id()) .start(() -> run(session)); } private void run(AgentSession session) { while (session.isNotTerminal()) { ActionResult result = orchestrationEngine.step(session); session.publishEvent(result); } } }你只要记住一个原则:AgentSession 的状态变更全部发生在同一个虚拟线程里,避免并发问题。外部事件(比如用户回复)通过队列投递到这个线程,而不是直接修改 session 状态。这个设计借鉴了 Actor 模型的思路,极大地降低了并发复杂度。
5.4 失败重试与幂等控制
工具执行不总是成功的,网络抖动、依赖服务超时都可能发生。我的重试策略放在编排引擎层而不是工具实现里,核心代码是:
public ToolResult executeWithRetry(ToolInvocation inv, RetryPolicy policy) { while (true) { try { return toolExecutor.execute(inv); } catch (ToolExecutionException e) { if (inv.retryCount() >= policy.maxRetries()) { throw e; } inv.markRetry(e.getMessage(), policy.nextBackoff(inv.retryCount())); Thread.sleep(policy.nextBackoff(inv.retryCount())); } } }注意,幂等控制非常关键。如果一个工具被模型反复要求执行多次,你不能每次都真的调外部系统。工具注册表里每个工具定义都带一个idempotencyKey策略,相同 key 且相同参数的调用直接返回上次缓存结果(如果工具声明了允许幂等)。这是真正影响生产稳定性的细节。
6. MCP 工具协议接入:为什么不用自己定义 JSON Schema
6.1 协议选择背后的思考
本来我可以自己定义一套工具描述格式,然后让模型通过 function calling 机制去调用,但最终我选择了 MCP 协议。原因很直接:Deepseek Harness 这类项目能火,靠的就是开放生态。如果我自己定义工具格式,后面每接入一个社区工具都要写一套适配器,这活干不完。MCP 把工具发现、工具调用、资源读取的协议都定好了,我只需要实现协议客户端,就能接入所有 MCP Server 提供的工具。
6.2 领域层的 ToolDefinition 与协议无关
在领域层,工具定义是这样的:
@Value.Immutable public interface ToolDefinition { String name(); String description(); Map<String, Object> inputSchema(); // JSON Schema 格式 ProtocolType protocolType(); // MCP / INTERNAL boolean idempotent(); }MCP 协议层的类型不会出现在这里。领域层只关心“我有没有一个叫 weather.forecast 的工具、参数结构是什么、是否幂等”。至于这个工具是通过 MCP Server 暴露的还是本地 Spring Bean 暴露的,对编排引擎来说是一样的。这种解耦是你后续接入千个工具的基础。
6.3 MCP 客户端的会话生命周期管理
MCP 客户端不是简单调一次就完事,一个 MCP Server 可能暴露一批工具,而且工具列表可能在运行期变化。我这边会做一个 MCP 服务器注册中心,启动时扫描全部 MCP Server,建立 Initialized 连接,随后同步工具定义到本地 Registry。每 60 秒做一次轻量级变更检查,发现工具列表变化时增量同步。
这里面最坑的一个点是命名冲突。不同 MCP Server 可能都提供了一个叫get_weather的工具。解决办法是在注册时强制加前缀,比如weather.get_weather。你会看到 MCP 官方的 Debug 页面里也是这种风格。你如果不做前缀隔离,到时候两个工具互相覆盖,报错都查不出来。
6.4 MCP 工具发现与动态加载
为了做到“插件即插即用”,我用 Spring 的事件监听机制做 MCP Server 的加载。一个 MCP Server 配置进来后,发布McpServerRegisteredEvent,监听器负责建立连接、拉取工具列表、注册进 Registry。整个过程对编排引擎完全透明。这也让整个系统的扩展性变得非常舒服——新增一个工具,只是新增一个 MCP Server 配置,代码不用动。
7. 上下文管理策略与状态机推进:最容易被低估的部分
7.1 为什么会话一长就崩
很多 Agent 应用,初期可用,中期卡顿,后期直接超时或者账单爆炸。根因就是上下文管理策略没设计好。我见过一个团队,把全部历史消息都发给模型,跑了 20 轮后单次请求的体积已经超过了模型上下文窗口,程序直接报错。这就是没有窗口管理意识。
7.2 滑动窗口 + 摘要压缩策略
我采用的策略是组合策略:滑动窗口保近、摘要压远、摘要数据单独落库。
具体参数可以这样配置:
| 策略项 | 配置值 | 说明 |
|---|---|---|
| 保留最近完整消息数 | 20 条 | 保证模型能看到最近的细节 |
| 摘要触发阈值 | 40% 上下文窗口 | 超过这个比例就触发压缩 |
| 摘要更新频率 | 每 10 轮一次 | 避免频繁压缩带来额外 token 开销 |
| 最大摘要长度 | 1024 tokens | 摘要本身也有长度控制 |
摘要的生成不能每次重算全部历史,那样浪费 token。改进版是增量摘要:保留上一版摘要,把窗口外新掉出去的消息追加摘要,再让模型合并两段。这样单次摘要成本从 O(N) 降到了 O(窗口外数量)。
7.3 消息裁剪的边界条件
消息裁剪有几个边界条件,特别容易被忽略:
- 工具调用配对不能拆散。一条 assistant 消息发起工具调用,后面跟着一条 tool 消息返回结果。如果裁剪时只保留 tool 结果而丢了调用请求,模型的记忆就断裂了。所以裁剪算法必须按”工具调用对“为单位做原子性裁剪。
- 系统提示词不能进入裁剪范围。系统提示词必须在每次请求时完整带上。
- 保留最后一次用户意图。即使窗口再紧张,也不能把用户最新的需求压缩掉,否则模型就不知道这次用户到底要什么了。
7.4 状态机如何在上下文压缩后保持推进
上下文压缩后,模型可能产生短暂的“失忆”,这也是我没把压缩做成纯异步的原因。压缩完成后,编排引擎会向模型发一条内部消息,内容是“之前的对话太长,我为你总结了要点:xxx。请接着回答用户的问题”。
这样做的效果是,模型依然认为自己在一个连续的任务里,而不会因为上下文的突然变化给出一个“抱歉,我不理解你在说什么”的答复。这个细节让你的 Agent 体验和别的 Agent 拉开差距。
7.5 会话状态与上下文持久化
AgentSession 的每次状态推进,我都会先持久化再处理下一步。这个顺序很重要——如果你先处理完再落库,万一进程在中间崩了,恢复的时候不知道从哪一个步骤继续。我的方案是:
- 状态变更事件先写入 outbox 表;
- 再执行实际的编排步骤;
- 如果编排失败,整个 session 回滚到最后一次稳定状态,从 outbox 重放。
这种玩法本质上是 event sourcing 的简化版,你不需要套 Axon 这种重框架,一个 outbox 表加一个重放接口就够用了。
8. 状态机、事件风暴与聚合的落地:把理论焊在代码里
8.1 从事件风暴产出聚合
事件风暴的产出物不是一屋子便利贴,而是直接可以映射到代码的领域模型。我在复刻时,把每个关键领域事件映射为聚合上的方法调用:
| 领域事件 | 聚合上的方法 | 产出动作 |
|---|---|---|
| UserPromptSubmitted | Conversation.append() | 追加用户消息,触发窗口管理 |
| AgentPlanProposed | AgentSession.plan() | 进入 PLANNING,保存计划摘要 |
| ToolInvocationRequested | ToolInvocation.start() | 记录待执行工具,等待执行 |
| ToolExecuted | ToolInvocation.finish() | 回填结果并发布 ToolResultAccepted |
| ToolResultAccepted | Conversation.append() | 把结果写入会话上下文 |
| ModelResponseProduced | AgentSession.complete() | 输出最终答案,状态转 COMPLETED |
这样一张表,你在开发的时候照着填方法就行,不会出现“事件发了但没人处理”的问题。这条表我强烈建议贴在自己工位上,比任何架构图都管用。
8.2 聚合的不变式与业务规则
聚合内是有不变量要维护的,我列几个典型:
- Conversation 聚合:任意时刻 messages 的估算 token 数不能超过预算上限;裁剪时不能拆散工具调用对。
- ToolInvocation 聚合:状态只能按状态机定义的方向流转,不允许从 FAILED 直接跳 EXECUTING。
- AgentSession 聚合:终止状态不可逆;WAITING_USER 状态下只有用户事件可以唤醒。
这些不变量我只写在聚合的入口方法里面,比如Conversation.append()守护 token 预算、AgentSession.complete()只接受非终止状态的调用。领域服务里不再重复校验,保持单一职责。
8.3 In-Memory 与持久化的状态一致性
这里补充一个我踩过的坑。当时我在内存里改了聚合状态,然后计划用事件总线把变更推给持久化组件,结果进程一崩,内存状态和数据库不一致,排查了好久。后来我改成:先在数据库侧应用事件,再把聚合状态刷新到内存。也就是先持久化后更新缓存,这样任何时刻崩溃都最多丢最后一条未持久化的事件。这个顺序问题,DDD 的教材通常不会告诉你,但生产环境特别重要。
9. Token 统计、耗时分析与可观测性:上线前必须考虑的事
9.1 为什么 Agent 系统的可观测性比传统后端更重要
传统后端接口的监控看 QPS、延迟、错误率就行。Agent 系统不一样,它的每一个请求都会引起多次模型调用,中间还穿插着工具执行,如果你没有细粒度的观测,一次“看起来很慢”的请求你根本不知道是模型响应慢,还是某个工具执行卡住了。所以我在系统里做了三层观测:
- 会话级 trace:每次完整请求一条 trace,下面挂模型调用 span 和工具调用 span;
- Token 计量表:每个模型调用记录 input_tokens、output_tokens、模型名、场景名;
- 工具指标表:每个工具调用的次数、平均耗时、失败率、重试次数。
9.2 基于 Micrometer 的指标埋点
我没有用一个重量级 APM 系统,而是直接基于 Micrometer 做指标埋点,因为 Spring Boot 原生集成。核心的埋点代码风格像这样:
@Timed(name = "agent.tool.execution", description = "Tool execution duration") public ToolResult execute(ToolInvocation inv) { long start = System.nanoTime(); try { ToolResult result = doExecute(inv); Metrics.counter("agent.tool.success", "tool", inv.toolName()).increment(); return result; } catch (Exception e) { Metrics.counter("agent.tool.failure", "tool", inv.toolName(), "error", e.getClass().getSimpleName()).increment(); throw e; } finally { executionTimeRecorder.record(inv.toolName(), TimeUnit.NANOSECONDS.toMillis(System.nanoTime() - start)); } }这些指标直接接入 Prometheus + Grafana,仪表盘上能清楚看到:
- 哪个工具调用最慢;
- 哪个模型供应商的 token 消耗最大;
- 哪个环节导致整个 Agent 流程卡住。
9.3 结构化日志与 traceId 贯穿
日志这块,我要求所有日志必须带sessionId、spanId、toolName、modelName这几个字段。这样你用日志查询系统查一个 session 的时候,能按时间线完整还原它每一步的决策过程。
我还会在 AgentSession 状态变更时打一条结构化 JSON 日志,格式大概是:
{ "event": "session_state_changed", "sessionId": "abc123", "fromState": "EXECUTING", "toState": "WAITING_USER", "reason": "model_requested_clarification", "tokenUsage": {"input": 1200, "output": 340} }这种日志在排障的时候比任何调试器都好用,因为你看到的是完整的状态推进路径,而不是零散的内部变量。
9.4 成本控制:这一项才是老板最关心的
最后说一个非技术但极其重要的事:token 成本。Agent 系统最大的特点就是每一次“用户提问”背后可能消耗大量 token。我在系统里做了预算控制,核心手段是:
- 每个会话设置 token 预算上限,超了自动降级为“简洁回答模式”;
- 每个模型调用前估算代价,如果剩余的上下文空间已经不足,提前触发压缩而不是硬塞;
- 对工具返回结果做截断,默认最大值是 2000 tokens,工具返回一个 10 万字的文档时会截取核心摘要。
这三个手段放在一起,能让你的 token 账单不再动不动就超出预期。说白了,Agent 系统的技术难点不在于模型,而在于你怎么用工程手段把一个不可控的资源消耗变得可控。
10. 复刻过程中踩过的坑:给想要动手的人提前排雷
10.1 Spring AI 的 Tool Callback 列表无法动态变更
第一个大坑是 Spring AI 对工具调用的处理。它要求你在请求时传入固定的 ToolCallback 集合,不支持在流式输出中途动态新增一个工具。这个问题在模拟代码 demo 时不会暴露,但一旦你接了 MCP,工具列表随时变化,就会很麻烦。我的解决方式是在 ModelAccessContext 实现层做了一层缓存:每次请求前,把当前 ToolRegistry 里的工具快照编译成 ToolCallback 列表。工具列表变更时清理缓存,下次请求重新构建。
10.2 模型输出的 JSON 参数总是不标准
大模型的工具调用参数虽然经过训练,但 JSON 格式偶尔会出幺蛾子,比如多余逗号、未转义引号。你必须有一个容错解析层。我用的是 Jackson 的 lenient 模式 + 自动补全尝试:先正常解析,失败后再用 lenient 模式,再失败才报错。实测能挽救掉三分之一的格式异常。
10.3 流式响应的背压问题
模型流式输出工具参数的时候,如果解析方处理得慢,会导致 TCP 连接拥塞。我用了个简单的背压:解析线程和 IO 线程之间用一个容量有限的队列,队列满时就暂停拉取流数据。这个设计避免了大文本参数输出时内存突增。
10.4 把非 MCP 平台工具隐藏在主编排循环之外
MCP 做外部工具接入很好,但有些平台内部工具(比如内容发布、数据查询网关)用 MCP 就没必要了,性能和调试成本更高。我保留了 INTERNAL 协议类型,内部工具直接注入为 Spring Bean,走本地方法调用,不经过网络协议栈。这样既享受了 MCP 的生态,又没有为了架构一致性牺牲内部工具的直连性能。
10.5 虚拟线程使用不当也可能踩坑
虚拟线程虽然便宜,但也不是无限量。如果你的每个 session 等都长时间运行,且会话数量暴涨,虚拟线程本身不占太多资源,但每个线程持有的关联数据(比如 Session 对象的引用)会让内存水位上升。所以我还是做了一个信号量限流,同一时间最多允许 200 个活跃 session,超过的排队等待。这个数字根据你自己的内存估算,别盲抄。另一个坑是虚拟线程内部接了数据库连接池的话,连接池大小设置不合理会导致全线程全员等待数据库连接。我的建议是 session 线程不直接持有数据库连接,需要持久化时通过独立的事务执行器去拿连接,这样能避免虚拟线程和连接池互相锁死。
11. 后续可以这样扩展:从单轮到多智能体
复刻完成后,系统已经具备一个 Agent Runtime 的最小完整能力集。如果你想继续深入,我建议下一步做多智能体编排(Multi-Agent Orchestration)。因为核心的 AgentSession 状态机已经支持 PLANNING、WAITING_USER 等中间状态,扩展多智能体时你不需要推翻重来,只需要把”一个 AgentSession 内部调工具“扩展成”一个 SuperAgentSession 协调多个子 AgentSession“。子 Agent 之间通过消息总线异步通信,父状态机聚合子状态机的完成信号。
这其实就是把单 Agent 的工具调用循环泛化为子 Agent 调度。领域模型层面只要增加一个AgentCoordinator聚合和AgentTask值对象,整体架构几乎不动。这也从一个侧面说明,DDD 前期建模不偷懒,后期扩展是真的省力。
最后再分享一个小技巧。如果你要给别人演示这个项目的架构,不要一上来就贴 DDD 分层图,而是先跑一遍完整的工具调用循环日志,让所有人看到模型是怎么一步步决策、调用、回填的。看到那条完整的事件链之后,再讲领域划分和状态机,大家一下就懂了。原理和现实之间的那座桥,永远是跑起来的东西。