Java+DDD复刻Deepseek Harness:大模型工具调用与Agent编排实践

Java+DDD复刻Deepseek Harness:大模型工具调用与Agent编排实践 先从结论说起我花了两周时间用 Java 21 Spring Boot 3 DDD 领域驱动设计把社区里那个很火的 Deepseek Harness 项目按 1:1 的思路重新实现了一遍。这里说的“1:1”不是逐行翻译源码而是把它的核心机制——大模型工具调用Function Calling的封装层、Agent 编排链路、上下文管理策略——用 DDD 的方式重新拆解、建模、落地。整个过程踩了不少坑也把很多“知其然不知其所以然”的细节彻底搞懂了。这个项目适合谁两类人。第一类是想搞清楚 Harness 和 Agent 到底差在哪、大模型工具调用链路内部是怎么跑的 Java 后端工程师第二类是正在学 DDD想知道领域建模在实际项目中怎么落地、而不是只会写“订单-商品-用户”教学案例的人。如果你两个点都占那这篇文章基本就是给你写的。我会把事件风暴怎么开、限界上下文怎么切、工具注册表怎么设计、Deepseek API 怎么接、上下文超限怎么处理全部按照我实际动手的过程讲一遍。1. 为什么要用 Java DDD 复刻一个 Harness1.1 Harness 到底是个什么东西很多同学第一次看到 Harness 这个词是在 AI Agent 相关的仓库里比如 Codex Harness、Deepseek Harness、Agent Harness。听着很唬人其实它的核心作用就一句话在模型和应用之间加一层“缰绳”。你看英文里 harness 本身就有“马具、挽具”的意思套在 AI 上就是控制模型行为的工具层。拆开看一个典型的 Harness 要做四件事工具注册与发现告诉模型“你现在有哪些工具可以用”比如查天气、算数、查数据库、调外部 API。工具调用的请求与响应编排模型输出一个结构化的“我想调用某某工具参数是某某”Harness 负责解析、执行、把结果回传给模型。对话上下文管理维护完整的消息历史并在超长时做裁剪或摘要保证多轮工具调用不迷路。安全与边界控制哪些工具能调、哪些不能调、调用超时怎么处理、并发怎么控制。如果你用过 ChatGPT 的插件功能或者 Deepseek 的 Function Calling那你已经在用 Harness 的思路了只不过那些是平台帮你封装好的。自己用 Java 复刻一遍相当于把黑盒拆开看里面到底是怎么转的。1.2 为什么选 Java而不是 Python我知道你肯定想问AI 生态不都是 Python 的天下吗用 Java 复刻一个 AI 框架是不是有点逆潮流我当时的判断是反过来的。第一团队现有技术栈就是 JavaSpring Boot 的生态成熟部署运维、监控告警、灰度发布这一套都是现成的为了一个几十万 token 的调用链路单独引入 Python 服务成本不划算。第二真实的企业级 AI 应用很少是纯模型调用它一定要和现有的业务系统打交道——订单系统、库存系统、权限系统这些在 Java 世界里已经沉淀了大量领域逻辑。与其用 Python 做一层薄薄的胶水层不如直接在 Java 里把 Harness 做成基础设施。第三点可能更现实一点Java 面试里AI 应用开发的经验正在变成高价值加分项。你去看现在的岗位要求尤其是一些中大型互联网公司的 Java 岗位已经在要求“熟悉大模型应用开发了解 Function Calling、Agent 编排”。而大多数人还停留在“用 Python 调一下官方 SDK demo”的水平。如果你能用 Java DDD 做出一套结构清晰、可扩展的 Harness这在面试聊起来是完全不同的深度。1.3 DDD 在 AI 项目里能发挥什么价值说实话DDD领域驱动设计这两年有点被妖魔化了。一提 DDD 就是事件风暴、聚合根、领域事件、CQRS一套组合拳下来很多人只记住了术语落到代码里还是 CRUD。但在这个 Harness 项目里DDD 的价值非常实在。因为 Harness 本身的业务复杂度并不低既要有模型接入、工具注册这类“技术域”又要有会话管理、消息流转这类“业务域”还有上下文策略、Token 计价这类杂糅了业务和技术的部分。如果不用领域模型把这些边界切清楚写到最后一定是一团互相引用的意大利面。DDD 的核心动作是识别限界上下文、建立通用语言、划分聚合边界。这几个动作做完你会发现一个 AI Harness 项目天然就是多个限界上下文的组合模型接入上下文、工具管理上下文、会话编排上下文、执行引擎上下文。每个上下文内部是自治的上下文之间通过明确的接口通信。这不只是写着舒服更是为了后续演进——今天接的是 Deepseek明天要接通义千问或者本地部署的模型只要模型接入上下文的适配层做得干净替换成本会非常低。2. 领域建模先用事件风暴把业务拆清楚2.1 事件风暴该怎么组织动代码之前我们拉了一个下午的事件风暴Event Storming工作坊。别被这个词吓到实际操作就是把业务方、后端、测试拉到一起用便利贴在墙上贴“领域事件”然后倒推触发这些事件的“命令”再找承载状态的“聚合”。对于 Harness 项目我们列出来的核心领域事件包括ToolRegistered工具已注册ToolInvocationRequested工具调用已请求ToolInvocationSucceeded工具调用成功ToolInvocationFailed工具调用失败MessageAppended消息已追加ContextTruncated上下文已裁剪SessionEnded会话已结束每个事件旁边贴上是谁触发的。比如 ToolInvocationRequested触发的命令来自模型输出的 tool_calls 字段ToolInvocationSucceeded触发的命令来自工具执行器的返回值。这样一贴整个系统的动态流程就出来了比看十遍架构图都直观。2.2 限界上下文划分五个边界清晰的模型域事件风暴做完我们把系统切成了五个限界上下文。这是整个 DDD 设计中最重要的决策直接决定后续代码结构长什么样。限界上下文核心职责关键领域对象模型接入上下文Model Access封装不同大模型 API 的差异统一调用入口ModelClient、ChatRequest、ChatResponse工具管理上下文Tool Management工具的注册、发现、参数 Schema 管理ToolRegistry、ToolDefinition、ToolSpec会话编排上下文Session Orchestration会话生命周期、消息历史维护、上下文策略ChatSession、MessageHistory、ContextStrategy工具执行上下文Tool Execution调起真实的工具逻辑、处理超时与异常ToolInvoker、InvocationResult、ToolException应用服务上下文Application对外提供 API串联上面四个上下文HarnessApplicationService、Facade这里有个常见的坑很多人会把“工具执行”和“工具管理”合并成一个上下文。我建议拆开。原因很简单工具管理关心的是“有哪些工具、长什么样”工具执行关心的是“怎么跑起来、出错了怎么处理”。两者变化的频率和原因完全不同。比如你给工具管理加一个注解扫描的新特性不该影响到执行器那部分代码。拆开后各自的聚合边界也更清晰。2.3 聚合与实体设计别把聚合根做成大泥球DDD 落地时最容易犯的错误是把聚合根做成一个大而全的对象什么字段都往里塞。我们这个项目里最重要的聚合根是 ChatSession它的设计就经历了从“大泥球”到“瘦身”的过程。第一版我把 ToolRegistry、MessageHistory、ContextStrategy 全部塞进 ChatSession字段有几十个。结果发现一个会话既要做消息追加又要管工具注册索引还要处理上下文裁剪任何一个小的变更都会牵动整个聚合测试也很难写。后来按 DDD 的原则重新梳理ChatSession 聚合根只维护最核心的不变条件包括会话 ID、关联的 ModelClient、消息列表以及当前会话的 Token 占用情况。工具注册不放在会话里因为工具是全局共享的上下文裁剪策略也不直接挂在会话实体上而是通过策略对象传入。聚合内的一致性通过聚合根统一对外提供方法保证。比如追加消息时ChatSession 内部会自己判断当前 Token 占用是否超过阈值如果超了就先触发 ContextStrategy 执行裁剪再追加新消息。这个“先裁剪再追加”的逻辑必须由聚合根保证不能让应用服务层来做否则以后换个调用入口就可能漏掉这个约束。2.4 通用语言落地团队先对齐名词再说代码DDD 强调通用语言Ubiquitous Language目的是让业务人员和开发人员说同一套词。我们这个项目里最典型的例子就是“工具”这个词的混乱。一开始团队里有人说“工具”有人说“插件”有人说“Skill”还有人说“Action”。代码里同时出现 Tool、Plugin、Skill 三个类名指向的却是同一个东西。后来我们花了一个小时统一术语表统一术语含义废弃说法Tool一个可被模型调用的功能单元插件、Skill、ActionToolCall模型发起的一次具体工具调用请求Function Call、工具调用记录ToolSpec工具的元数据和参数 Schema 定义工具描述、OpenAPI 定义Harness整套模型-工具编排引擎Agent 框架、插件系统Session一次多轮对话的上下文载体对话、聊天记录术语统一之后代码命名、数据库表名、API 字段名全部跟着改。这个动作看起来不产生任何业务功能但后面写代码时效率至少提升 30%因为大家不用再互相问“你这个 plugin 指的是哪种 plugin”。面试时如果聊到 DDD这个例子也很能说明你对通用语言的理解不是停留在概念层面。3. 核心链路实现从工具注册到 Deepseek 调用3.1 分层架构与工程目录限界上下文确定后代码结构就顺理成章了。我采用的是经典的 DDD 分层接口层interfaces→ 应用层application→ 领域层domain→ 基础设施层infrastructure。com.example.ds-harness ├── interfaces — Controller、DTO、请求校验 ├── application — 应用服务、DTO 转换、事务编排 ├── domain │ ├── model — 模型接入上下文 │ ├── tool — 工具管理上下文 工具执行上下文 │ ├── session — 会话编排上下文 │ └── shared — 通用值对象、领域事件 └── infrastructure ├── client — DeepseekApiClient、OkHttp/WebClient 封装 ├── repository — Redis 实现、JPA 实现 └── config — 配置项、Bean 装配这里有个实操体会domain 包下面不要再按“实体、值对象、仓库接口、领域服务”这种技术分类建子包而是按业务上下文建包。比如 session 上下文里自然会有 ChatSession 实体、Message 值对象、ContextStrategy 接口、ChatSessionRepository 接口。这样你一眼就能看出“这个上下文有哪些领域概念”而不是还得一个包一个包点开看。不少人学了 DDD 但代码看着还是像三层架构就是因为包的切法根本没按领域来。3.2 工具注册与发现让模型知道“你能干什么”工具注册是整个 Harness 的基础。模型本身不知道你的系统有哪些能力你得在请求里带上工具清单。我们通过自定义注解 注册表的方式实现。核心是一个 HarnessTool 注解Target(ElementType.METHOD) Retention(RetentionPolicy.RUNTIME) public interface HarnessTool { String name(); String description(); String[] parameterNames() default {}; String[] parameterDescriptions() default {}; boolean required() default true; }然后定义一个 ToolRegistry 接口属于工具管理上下文的领域层public interface ToolRegistry { void register(ToolDefinition definition); OptionalToolDefinition find(String name); ListToolDefinition findAll(); }基础设施层提供一个基于 Spring 容器扫描的实现启动的时候把带有 HarnessTool 注解的方法自动注册进去。但这里有个最关键的细节工具的注册信息最终要转换成大模型能识别的 JSON Schema 格式。Deepseek 的 Function Calling 接口遵循 OpenAI 的格式每个工具需要提供 type、function、parameters 三件套。我自己封装了一个转换器把注解里的参数名和描述转成 JSON Schemapublic class ToolSpecGenerator { public MapString, Object generateSpec(ToolDefinition def) { MapString, Object parameters new HashMap(); parameters.put(type, object); ListMapString, Object properties def.getParameters().stream() .map(p - { MapString, Object prop new HashMap(); prop.put(type, string); prop.put(description, p.getDescription()); return prop; }).collect(Collectors.toList()); parameters.put(properties, properties); MapString, Object function new HashMap(); function.put(name, def.getName()); function.put(description, def.getDescription()); function.put(parameters, parameters); MapString, Object spec new HashMap(); spec.put(type, function); spec.put(function, function); return spec; } }这个转换看起来简单但有一个特别容易被坑的地方如果参数没有声明 enum 或者 format某些模型会把所有参数都当字符串处理导致传数字类型的参数时模型给出一个字符串。后面第 4 节我会专门讲这个问题。3.3 工具调用编排一次完整的 ReAct 循环工具调用的编排是整个 Harness 的核心链路。我把它理解成一个带终止条件的 while 循环每一步做五件事把系统提示词、历史消息、工具清单组装成请求。调用 Deepseek API拿到模型响应。判断响应里有没有 tool_calls。没有说明模型觉得任务完成了直接返回给用户。有 tool_calls遍历每一个调用去 ToolRegistry 找到对应工具执行。把每个工具的执行结果以 “tool” 角色的消息追加回会话再次组装请求调用模型。这个循环的经典称呼是 ReActReasoning Acting也就是“思考-行动-观察”的循环。模型先思考决定要调什么工具系统执行工具把结果返回给模型“观察”模型再继续思考。直到模型觉得不需要工具了输出最终答案。我建议最大循环次数限制在 5-8 轮防止模型陷入无限调用。比如模型反复调用一个成功的工具把结果回传后又调用同一个工具这种循环在真实场景里非常常见。我们的做法是在应用服务层设置 maxIterations 参数默认 5超出后直接抛出 MaxIterationExceededException把当前的消息历史原样返回给前端让前端提示用户“当前任务太复杂请拆分成多个子任务”。3.4 Deepseek API 接入细节兼容 OpenAI 格式的 ClientDeepseek 的 API 设计成了兼容 OpenAI 格式所以调用方式非常直接。我们用 Spring 的 WebClient 封装了一个基础设施层的 ModelClient 实现。先定义领域层接口public interface ModelClient { ChatResponse chat(ChatRequest request); FluxChatResponse chatStream(ChatRequest request); }基础设施层实现 DeepseekModelClient核心就是拼这个 JSON 请求体{ model: deepseek-chat, messages: [ {role: system, content: 你是 Harness 引擎的助手。}, {role: user, content: 帮我查一下北京今天的天气} ], tools: [ { type: function, function: { name: weather_query, description: 查询城市天气, parameters: { type: object, properties: { city: {type: string, description: 城市名} }, required: [city] } } } ], tool_choice: auto }技术细节上我用的 WebClient 响应超时设置为 60 秒连接超时 10 秒。流式接口在低延迟场景下更好用但会显著增加代码复杂度因为 SSEServer-Sent Events流的解析、工具调用结果的流式回传都要处理。我的建议是初期先做非流式把链路跑通再考虑流式优化。接入配置放在 application.yml 里通过 ConfigurationProperties 绑定到 DeepseekPropertiesdeepseek: api-key: ${DEEPSEEK_API_KEY} base-url: https://api.deepseek.com model: deepseek-chat temperature: 0.7 max-tokens: 4096有个非常容易忽略的点tool_choice参数。默认值是auto意思是模型自己决定要不要调工具。但在某些场景下你需要强制模型调用某个工具比如用户说“把这段文本翻译成英文”而你只有一个translate工具这时候可以把tool_choice设置为{type: function, function: {name: translate}}模型就不会跑偏去自己瞎翻译了。这一点在我实际测试中对稳定性的提升非常明显。3.5 上下文管理会话不能无限膨胀聊过 Deepseek 的人应该都遇到过“对话长度达到上限请开启新对话”的提示。这个问题的本质是模型有上下文窗口限制Deepseek-V3 的上下文窗口大约 64K token超了就得清。但清掉了前面的信息多轮工具调用就会断掉比如模型前面说过“我接下来会查库存”后面突然被截断它就不记得了。我设计了分层的上下文管理策略核心接口是 ContextStrategypublic interface ContextStrategy { ListMessage compact(ListMessage messages, int maxTokens); }实现类至少需要这三种策略滑动窗口裁剪SlidingWindowTruncationStrategy只保留最近的 N 条消息。优点是实现简单、速度快缺点是中间过程全部丢失多轮工具调用的链路一旦被截断后续就可能出现上下文不一致。摘要压缩SummarizationContextStrategy调用模型把前面的对话总结成一段摘要作为 system 消息填充。保留信息完整性更好但多了一次模型调用增加延迟和成本。关键信息保留KeyInfoPreservationStrategy从工具调用结果中提取关键字段保留。这是最轻量、最高性价比的方案适合工具调用结果很大、但诊断信息并不需要的场景。我实际采用的组合策略是优先做关键信息保留把上一轮的 ToolCall 和 ToolResult 精简成一行摘要比如[工具: query_weather, 参数: {city: 北京}, 结果: 晴, 25°C]。如果消息数仍然超过阈值再走摘要压缩。滑动窗口作为最后的保底方案。这一套做完连续 20 轮工具调用的场景基本不会触顶。4. 实战踩坑与排查记录4.1 报错一Deepseek 一直提示“达到对话长度上限请开启新对话”这个错误几乎每个接 Deepseek 的人都会遇到。我一开始以为是没做上下文裁剪后来排查才发现更隐蔽的问题工具调用结果里有冗余的原始响应结构。我在把 ToolResult 追加回消息时直接放入了整个 JSON 响应体包括 HTTP 状态码、响应头、调用链路的 traceId 等等。一次工具调用无害但 5 轮工具调用后这些元信息可能占掉上万 token。解决方案很简单定义 Message 值对象时只保留 role、content、toolCallId、name 这几个核心字段工具原始响应只保留 data 部分其他全部丢弃。同时设置单个 ToolResult 的最大长度超过 2000 字自动截断。这样做之后同场景下 token 占用少了 60% 以上连续多轮调用也再没触顶。4.2 报错二模型的 tool_calls 参数经常解析失败另一个高频问题是模型返回的 tool_calls 里的参数是个 JSON 字符串而不是结构化的 JSON 对象。比如模型可能返回{ tool_calls: [{ function: { name: weather_query, arguments: {\city\:\北京\} } }] }注意 arguments 字段是字符串。如果你直接拿这个字符串去做toolCall.getArguments().get(city)一定报错或者拿到 null。正确做法是先反序列化成 JsonNode 再取值同时要做异常兜底因为模型偶尔会生成非法的 JSON比如多个 JSON 拼接。我写了一个安全的工具调用参数解析器做了三重兜底先尝试正常反序列化失败后用容错 JSON 解析库比如 json-sanitizer清洗后再次解析还失败就返回一个空的参数 Map并附带一条 warning 消息回传给模型让它知道参数解析失败。这个兜底逻辑在实际运行中救了很多次。4.3 报错三工具参数的 JSON Schema 类型不对模型把数字当了字符串有一次在本地调试我注册了一个入参为int类型的工具但没在 HarnessTool 注解里声明参数类型。结果模型连续三次调用都传入25而工具的 Java 方法签名明明是int。我一开始以为是类型没问题后来才发现是 ToolSpecGenerator 里默认把所有参数都标成了string。Deepseek 对 Schema 类型非常敏感你标了 string模型就会老老实实传字符串。修复很简单在注解里增加参数类型声明支持string、number、integer、boolean、object五类并在转换时做映射。另外还要处理enum情况如果某个参数只允许几个枚举值最好在 Schema 里显式声明enum列表否则模型可能自由发挥。4.4 并发场景Redis 自增计数和幂等控制如果你把 Harness 部署成多实例服务会话状态就不能只存在本地内存里。我用 Redis 存储会话上下文和工具调用计数。这里遇到一个很实际的问题用 Redis 的increment()做工具调用次数限制时第一次会报错“not an integer or out of range”因为 key 不存在increment()在有些配置下返回的不是预期的数字而是把空值当成异常处理了。排查后确认是 Jedis 和 Spring Data Redis 的版本行为差异。正确用法是先判断 key 是否不存在不存在就先setIfAbsent(key, 0, Duration.ofMinutes(5))初始化再increment()。同时注意设置过期时间因为工具调用的频次限制通常是按时间窗口计算的比如一分钟最多调用 100 次。我最后封装了一个 RateLimiter 组件基于 Redis Lua 脚本保证原子性避免并发场景下计数错乱。4.5 DDD 落地中的典型争议与我的取舍最后说一些 DDD 落地时的真实感受。网上对 DDD 的批评主要集中在“过度设计”“聚合根边界混乱”“事务跨聚合导致一致性难保证”这几个点。我在这个项目里也踩过。争议最大的一个问题是事务。DDD 的原则是一个事务只修改一个聚合。但 Harness 的工具执行场景天然要跨上下文工具执行要记录日志属于另一个聚合会话要确认已调用状态还有可能扣费。完全做到单一聚合事务不现实。我的取舍是核心会话状态的一致性用本地事务保证工具调用日志和计费用领域事件做异步解耦。具体地ChatSession 追加一个 ToolCall 并成功执行后发布 ToolInvocationSucceededEvent由监听器异步更新日志聚合和计费聚合。这样既保证了主链路的强一致又避免了跨聚合的大事务。另一个争议是贫血模型。我发现自己最初写的领域层实际上还是贫血模型——ChatSession 只有 getter/setter所有逻辑都在应用服务里。后来我强制自己把“追加消息”“执行裁剪”“判断会话结束”这些行为移到实体内部。改造之后应用服务的代码量大概减少了 40%而且测试好写很多因为核心业务逻辑可以脱离 Spring 容器直接做单元测试。这个转变让我真正理解了“行为归属”在 DDD 里的分量。4.6 常见问题速查表现象根本原因解决方案提示“对话长度上限”工具响应未裁剪或未做上下文策略只保留 data 字段单条响应限长分层压缩策略tool_calls 解析报错arguments 是 JSON 字符串先反序列化再取值加容错解析兜底模型把数字参数传成字符串JSON Schema 未声明参数类型注解增加参数类型映射优先用 integer/number上下文历史丢失滑动窗口截断太激进改用关键信息保留 摘要压缩组合策略increment()报 not integerRedis key 不存在或版本差异先 setIfAbsent 初始化再自增用 Lua 脚本保证原子性多轮循环不停缺少最大迭代次数限制设置 maxIterations超出后抛出异常并返回中间结果默认把所有参数标为 stringToolSpecGenerator 生成逻辑遗漏补充参数类型映射支持 enum 和 object5. 这套项目后续还能怎么扩展复刻完核心链路之后我明显感觉到这块内容就像积木往里加东西非常顺。我自己已经验证过的几个扩展方向可以分享给大家参考。一个是做多模型适配。因为这个项目是 DDD 分层ModelClient 接口在领域层Deepseek 的实现只是基础设施层的一个 Bean。再加一个通义千问或本地模型的实现只需要在基础设施层新增一个 Client然后在配置类里根据配置项切换 Bean。限界上下文的优势在这时候体现得最明显——模型接入的改动完全不会碰工具管理或会话编排的代码。另一个是把 Harness 改造成一个可观测的系统。我现在在会话上下文中埋了 traceId把每一次工具调用的名称、参数、耗时、token 消耗都通过领域事件发到消息队列再由一个监控服务做聚合展示。这样做最大的价值是能统计出哪些工具被调得最多、哪一步最耗时、token 消耗集中在哪类任务上。这些数据反过来可以优化上下文压缩策略和工具描述文案——模型返回 tool_calls 的准确率和工具 description 写得好不好有非常大的关系。如果你正在准备 Java 面试这个项目的面试价值也很高。几乎每一个点都能当独立话题展开DDD 聚合设计聊设计能力工具注册表的反射实现聊 Java 基础Redis 自增和事务边界聊并发和数据一致性WebClient 超时设置聊八股文里的网络编程。而且因为是你自己动手复刻的聊起来会有真实细节不是背出来的。最后再分享一个个人体会。刚开始复刻时我特别想在代码层面做到“原封不动 1:1”后来发现比起逐行对齐真正有价值的是把它的机制理解透之后用自己擅长的方式重写一遍。Java DDD 的组合逼着我把 AI 应用开发里那些“用脚本一把梭”的模糊地带变成了一个个明确的领域边界和可测试的单元。这个过程比单纯跑通一个 demo 学到的多得多。遇到“对话长度上限”或者“tool_calls 解析失败”的时候也别慌你先把链路打印出来看看消息历史在每一步到底长什么样问题大多一眼就能看出来。