ARTICLE DETAIL

资讯详情

深耕郑州网站建设与运营推广的一线实战洞察。

Java复刻Deepseek Harness:DDD驱动的Agent编排架构实战

Java复刻Deepseek Harness:DDD驱动的Agent编排架构实战 先给结论与其满世界搜“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, WindowSizePolicyToolInvocationContext管理工具定义、参数 Schema、执行状态ToolDefinition, ToolInvocation, InvocationStateAgentRuntimeContext编排 Agent 循环、驱动状态流转AgentSession, AgentAction, OrchestrationEngineModelAccessContext对接外部模型、流式解析ChatPort, ModelRouterObservabilityContext打点、记录 token 消耗、耗时分布TokenUsage, TraceSpan, ToolMetrics先别急着写代码对照你自己的项目想想是不是经常把 Message 和 ToolDefinition 硬塞到同一个 Entity 里导致谁都依赖谁这两个上下文切开的动作是整个项目后面最值钱的一步。3.2 上下文映射共享内核还是防腐层每个限界上下文之间的协作方式我给出了三层设计ConversationContext 对外暴露AppendMessage和WindowCompact接口其他上下文不直接操作 Message 表ToolInvocationContext 对外暴露一个ToolRegistry只读视图谁要执行工具必须通过 ApplicationService不能直接 new 一个 ToolModelAccessContext 对上是防腐层它内部的 Spring AI 依赖不会穿透到其他上下文。你只需要抓住一句话边界之间只能用接口通信接口参数必须是值对象或者普通 DTO绝不允许把对方领域里的实体传进来。这句话做到了你的 DDD 至少及格了。3.3 事件风暴产出的关键事件流事件风暴梳理出来最核心的事件流是这样一条链UserPromptSubmitted - AgentPlanProposed - ToolInvocationRequested - ToolExecuted - ToolResultAccepted - AgentTurnCompleted循环回到 ToolInvocationRequested 或进入 ModelResponseProduced这条事件链是整个编排系统的命脉。你写代码之前先把这条链想清楚写的时候就会异常顺。尤其是你后面想加人机协同、加失败重试策略事件驱动设计可以让你在不改变主干流程的情况下扩展。4. 核心聚合设计Conversation、ToolInvocation 与 AgentSession4.1 Conversation 聚合不要裸放 List很多 Agent 框架里对话历史就是ListMessage用的时候直接全量塞给模型。一开始能用但等你一个会话跑到几十轮之后Token 账单会让你肉疼。我把 Conversation 设计成了一个自管理上下文窗口的聚合根Aggregate public class Conversation { private final ConversationId id; private final ListMessage 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() { ListMessage 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 Schema6.1 协议选择背后的思考本来我可以自己定义一套工具描述格式然后让模型通过 function calling 机制去调用但最终我选择了 MCP 协议。原因很直接Deepseek Harness 这类项目能火靠的就是开放生态。如果我自己定义工具格式后面每接入一个社区工具都要写一套适配器这活干不完。MCP 把工具发现、工具调用、资源读取的协议都定好了我只需要实现协议客户端就能接入所有 MCP Server 提供的工具。6.2 领域层的 ToolDefinition 与协议无关在领域层工具定义是这样的Value.Immutable public interface ToolDefinition { String name(); String description(); MapString, 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 从事件风暴产出聚合事件风暴的产出物不是一屋子便利贴而是直接可以映射到代码的领域模型。我在复刻时把每个关键领域事件映射为聚合上的方法调用领域事件聚合上的方法产出动作UserPromptSubmittedConversation.append()追加用户消息触发窗口管理AgentPlanProposedAgentSession.plan()进入 PLANNING保存计划摘要ToolInvocationRequestedToolInvocation.start()记录待执行工具等待执行ToolExecutedToolInvocation.finish()回填结果并发布 ToolResultAcceptedToolResultAcceptedConversation.append()把结果写入会话上下文ModelResponseProducedAgentSession.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 和工具调用 spanToken 计量表每个模型调用记录 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 分层图而是先跑一遍完整的工具调用循环日志让所有人看到模型是怎么一步步决策、调用、回填的。看到那条完整的事件链之后再讲领域划分和状态机大家一下就懂了。原理和现实之间的那座桥永远是跑起来的东西。
返回列表