
1. 项目概述与核心思路拆解1.1 Deepseek Harness 是什么为什么要复刻“Harness”这个词在工程领域通常是“测试夹具”或“集成工具”的意思。放在大模型应用场景里Deepseek Harness 可以理解为一套围绕 DeepSeek 模型 API 构建的开发脚手架统一管理 Prompt 组装、会话上下文、工具调用、响应解析、轮询异步任务、重试降级等逻辑。说白了就是让后端团队不用关心“怎么和模型对话”这些重复工作只要聚焦业务本身。我之前在一个面试复盘里看到有人提到“用 Java DDD 复刻 Deepseek Harness”第一反应是这不就是把一个 Python 生态里很常见的大模型封装层搬到 Java 服务里重新做一遍吗但真做起来才发现这里面最难的并不是 HTTP 调用而是怎么用 DDD 的方式把模型交互的复杂性拆开。模型服务本身是无状态的但真实业务里需要多轮会话、工具调用、结果缓存、异常恢复这时候如果没有清晰的领域模型代码很快就会腐烂成一个大泥球。复刻的另一个理由是参考。很多团队在接大模型 API 时都是“先跑通再说”等到要做生产级的超时控制、鉴权、限流、审计日志时才发现没有一个可扩展的骨架。我花了两周时间把一个 1:1 行为对齐的 Harness 用 Java 重写并把 DDD 的分层落进去。这篇文章就是完整复盘包含领域建模、API 适配层、应用服务的协作方式以及我在实际开发中踩过的坑。1.2 为什么选 Java DDD而不是 Python 或脚本语言现在写 AI 工具链大多数人首选 Python因为生态里现成的 SDK 多requests 一发就能跑。但如果你要做的是企业级后端服务或者要嵌入到已有的 Spring Cloud 体系里Java 仍然是最稳的选择。尤其当你面对的是高并发、长连接、链路追踪、监控告警这些硬指标时Java 的类型系统和成熟的生态能帮你兜住很多问题。DDD 的价值在于它逼着你先想清楚“模型调用”这个领域里到底有哪些核心概念。很多人写 AI 集成代码上手就是 controller 里直接 new 一个 HttpClient然后发消息、拼字符串处理返回。短期看没问题等你要同时支持多个模型、多个 Prompt 模板、多种工具调用协议时代码就乱得没法收场。DDD 并不是银弹但它至少提供了一套讨论语言实体、值对象、聚合、仓储、领域服务、应用服务。用这套语言建模后续加功能、加模型、加策略都会舒服很多。还有一点是 1:1 复刻的需求。原版 Harness 可能有某些交互细节比如请求参数里的 temperature 范围、stream 模式的增量返回、工具调用的 schema 验证。这些行为如果没有清晰模型很容易在适配时被搞乱。DDD 里的值对象可以很好地描述这类不可变规则比散落的 Map 更可靠。1.3 整体架构与模块划分我做的是标准的多模块 Maven 工程按照 DDD 的分层原则拆成下面几个模块harness-domain领域层包含模型调用相关的核心概念不依赖任何 Spring 或 HTTP 框架。harness-application应用层编排用例如“执行一次单轮问答”“执行多轮对话”“执行带工具调用的请求”。harness-infrastructure基础设施层实现 HTTP 调用、JSON 序列化、配置加载、缓存、重试等。harness-interfaces接口层对外提供 REST API 或内部 RPC 接口。harness-starterSpring Boot Starter负责自动装配让业务项目直接依赖并开箱即用。这个划分看着简单但执行起来有个关键点依赖方向必须从外向内领域层不能反过来依赖基础设施层。比如领域层定义了一个ChatCompletionGateway接口基础设施层才提供DeepSeekChatCompletionGateway实现。这样后面换成别的模型或者 mock 测试都非常容易。2. 领域建模与 DDD 战略设计2.1 事件风暴与核心域识别做 DDD 项目我习惯先组织一次轻量级事件风暴。不需要真找一屋子人贴便利贴就拉上两三个对业务很熟的人把“一次模型调用”的生命周期从头到尾过一遍。我们梳理出来的核心事件是PromptReceived收到提示词、RequestBuilt请求参数组装完成、ApiRequested请求发往 DeepSeek、StreamChunkReceived流式响应收到分片、CompletionAccepted完整结果被接收、ToolCallRequired模型要求调用工具、ToolResultSubmitted工具结果回传。把这些事件串起来后核心域就很清楚了会话管理、请求构造、响应处理、工具调用。支撑域则是配置管理、模型端点适配、调用统计、鉴权等。在复刻过程中我发现真正值得投入建模的是会话和工具调用。因为大模型接口本身不复杂复杂的是会话上下文怎么组织、工具调用的状态机怎么流转。这两个模块用 DDD 重写后后续维护成本会低很多。2.2 聚合、实体与值对象划分聚合是 DDD 里最容易过度设计的地方。我的原则是能不做聚合根就不做但必须有明确的归属关系。这个项目里最明显的聚合根是Conversation会话。一个Conversation包含ConversationId、HistoryMessageList、ContextWindowSize、SessionConfig等。HistoryMessageList作为值对象而不是实体因为消息列表整体替换比逐条修改更合理——这符合大模型 API 的设计习惯每次请求都携带完整的历史消息列表。实体方面Message是一个典型实体它包含messageId、role、content、timestamp每次修改算一次新版本。Message不是值对象的原因是它会被持久化、被引用、被追溯而且会有状态变化。但Role和ContentBlock就是值对象不可变、等值比较、直接替换。还有一个容易忽略的聚合ToolInvocation工具调用。在一次模型请求中模型可能返回一个或多个工具调用请求每个工具调用有自己的toolCallId、toolName、arguments执行完成后得到一个toolResult最后这些结果再拼成新的上下文发回模型。这里我单独把ToolInvocation作为聚合根因为它的生命周期相对独立而且状态迁移复杂Pending-Executing-Succeeded/Failed。后续想在工具层加超时、重试、并发控制在这个模型里都非常顺手。2.3 限界上下文与防腐层复刻过程中我圈定了三个限界上下文ModelAccess负责和 DeepSeek API 交互包括请求发送、响应解析、流式读取。ConversationManagement负责会话生命周期、历史消息管理、上下文裁剪。ToolExecution负责工具注册、工具调度、结果回传。三个上下文之间通过接口通信各自独立演化。特别注意ModelAccess和其他上下文之间需要防腐层。DeepSeek API 的返回结构可能会变比如早期版本content可能是字符串后来变成数组结构。如果不做防腐层这些变动会直接渗透到业务层。我在基础设施层实现了一个DeepSeekDtoConverter专门把外部 DTO 转换成领域对象。就算未来 API 结构大变也只需要改这一个转换器。防腐层的另一个用途是屏蔽“模型能力差异”。不同的模型即便是同一家公司可能max_tokens的行为也不一致。我在领域层定义了自己的TokenBudget值对象然后在基础设施层根据实际模型映射到具体的参数上。这样上层代码只认自己的语言不会被底层细节绑架。3. 基础设施层与 API 适配实现3.1 用 WebClient 封装 DeepSeek 接口项目使用 Spring WebFlux 的 WebClient 而不是 RestTemplate。原因很简单WebClient 既支持同步阻塞调用也支持异步流式调用而流式返回是大模型交互的核心场景。DeepSeek 的streamtrue模式会返回 SSEServer-Sent Events格式的数据WebClient 的retrieve().bodyToFlux(String.class)可以直接处理这类响应流。我会在基础设施层定义单独的 HTTP 客户端 Bean不直接用默认配置。核心代码大概是Configuration public class DeepSeekWebClientConfig { Bean(deepSeekWebClient) public WebClient deepSeekWebClient(DeepSeekProperties properties) { HttpClient httpClient HttpClient.create() .option(ChannelOption.CONNECT_TIMEOUT_MILLIS, properties.getConnectTimeoutMs()) .responseTimeout(Duration.ofMillis(properties.getReadTimeoutMs())) .doOnConnected(conn - conn .addHandlerLast(new ReadTimeoutHandler(properties.getReadTimeoutSeconds())) .addHandlerLast(new WriteTimeoutHandler(properties.getWriteTimeoutSeconds()))); return WebClient.builder() .baseUrl(properties.getBaseUrl()) .defaultHeader(HttpHeaders.CONTENT_TYPE, MediaType.APPLICATION_JSON_VALUE) .clientConnector(new ReactorClientHttpConnector(httpClient)) .build(); } }注意连接池大小。模型接口并发不像数据库连接那么要求严苛但也不能无限制创建连接。我这里用默认连接池调了maxConnections为 200pendingAcquireTimeout为 10 秒。这个配置经验值是每路并发请求不要超过连接池一半否则高负载下容易拿不到连接。3.2 超时、重试与熔断参数设置大模型接口最大的特点是慢而且响应时间方差极大。普通业务接口可能 99% 在 200ms 内返回大模型接口从 1 秒到 60 秒都有可能。所以在做超时控制时必须区分连接超时、读取超时和整个请求超时。连接超时5 秒超过就说明网络或服务有严重问题。读取超时30 秒但如果开了流式响应这个超时不生效因为 SSE 会持续有数据包。整体请求超时非流式请求可以设 60 秒流式请求原则上不设整体超时但要靠空闲超时从收到最后一个 chunk 开始计算来判断是否断流。重试要格外小心。DeepSeek 这类模型接口部分错误是可以重试的比如 HTTP 429请求过多、502/503服务暂时不可用。但 400 这样的参数错误重试毫无意义。我在基础设施层定义了一个RetryableResponsePredicate只对可重试状态码做重试并且采用指数退避加抖动public MonoDeepSeekChatResponse chatCompletion(DeepSeekChatRequest request) { return webClient.post() .uri(/chat/completions) .bodyValue(request) .retrieve() .bodyToMono(DeepSeekChatResponse.class) .retryWhen(Retry.backoff(3, Duration.ofMillis(500)) .maxBackoff(Duration.ofSeconds(10)) .jitter(0.5) .filter(this::isRetryable)); }重试次数我最终定的是 3 次。别贪多模型调用是昂贵操作重试 3 次以上不仅浪费时间还会放大成本。熔断用的是 Resilience4j配置了滑动窗口大小 20失败率阈值 50%熔断后等待 10 秒再半开。这里有一个重要经验熔断应该只针对可重试错误不要把超时和业务错误混在一起否则一个错误的 prompt 格式就能把整个服务熔断。3.3 配置管理与敏感信息处理配置管理上我用了 Spring Boot 的ConfigurationProperties来绑定自定义配置项前缀是deepseek.harness。核心配置如下deepseek: harness: base-url: https://api.deepseek.com api-key: ${DEEPSEEK_API_KEY} connect-timeout-ms: 5000 read-timeout-seconds: 30 max-tokens: 2048 temperature: 0.7 stream-enabled: trueAPI Key 一定不能写在配置仓库里用环境变量或密钥管理服务注入。而且在实际调用时可以针对不同用户或不同项目做 API Key 隔离这样既方便审计也方便配额管理。配置里最容易忽视的是temperature和max_tokens。在领域层这两个字段我建模为GenerationParams值对象内部会做校验temperature必须在 0 到 2 之间max_tokens必须大于 0。每次请求进来应用服务会读取会话配置和全局默认配置合并最后传给基础设施层。这样下游拿到的永远是一个完整、合法的参数对象。4. 应用服务与应用层协作4.1 应用服务编排典型用例领域层只负责核心规则但实际用例要由应用服务来编排。拿“多轮对话”这个用例举例应用层要做的事情包括根据conversationId从仓储加载会话。把用户输入转换成PromptMessage追加到会话历史。如果历史超过上下文窗口调用上下文裁剪策略保留摘要。调用ChatCompletionGateway发送请求。如果响应里包含工具调用进入工具执行流程。把最终结果追加到历史返回给调用方。这段逻辑如果放在 Controller 里那就等于没有应用层。我单独写了ConversationAppServiceService RequiredArgsConstructor public class ConversationAppService { private final ConversationRepository conversationRepository; private final ChatCompletionGateway chatCompletionGateway; private final ToolExecutionAppService toolExecutionAppService; private final PromptHistoryCompressor historyCompressor; public CompletionResult sendMessage(SendMessageCommand command) { Conversation conversation conversationRepository.findById(command.getConversationId()) .orElseThrow(() - new ConversationNotFoundException(command.getConversationId())); conversation.appendUserMessage(command.getContent()); CompletionResult result chatCompletionGateway.chat(conversation.buildChatRequest()); if (result.hasToolCalls()) { ToolExecutionResult toolResult toolExecutionAppService.executeToolCalls( result.getToolCalls(), command.getUserId()); result continueWithToolResult(conversation, result, toolResult); } conversation.appendAssistantMessage(result.getContent()); conversationRepository.save(conversation); return result; } }这段代码看起来不长但所有“下一步干什么”的判断都在应用层业务规则在领域层。这样一来加一个“管理员修改会话配置后再发消息”的用例就只需要在应用服务里多组合一步领域模型不跟着变。4.2 领域事件驱动的扩展点复刻原版时原版可能有很灵活的插件机制比如自定义拦截器、自定义审计日志、自定义工具。在 DDD 里我通过领域事件来解耦扩展点。领域层定义事件接口和基础事件比如MessageSentEventToolCalledEventRequestFailedEventSessionExpiredEvent基础设施层或者应用层可以通过事件监听器来响应这些事件。比如我想统计每个工具调用的耗时就实现一个ToolCalledEventListener监听ToolCalledEvent记录耗时和结果。这个机制比硬编码日志强很多新增功能不需要改动核心链路的代码。事件发布我用的是 Spring 的ApplicationEventPublisher。有人觉得 DDD 里最好用自己的事件总线但对于大多数项目Spring 自带的事件机制足够用了。只有当你需要跨 JVM 发布事件时才考虑引入 Kafka 或 RocketMQ。做 1:1 复刻时我们只需要保证“行为一致”不一定要照搬事件存储机制。4.3 事务与幂等性设计模型调用接口是长耗时操作不能把 HTTP 调用放到数据库事务里。我这边的事务边界只覆盖“更新会话状态”和“保存调用记录”真正的网络请求在事务外层。为了保证数据一致性我用了一个简单的事件表方案请求开始前插入一条ChatRequestRecord状态为PROCESSING收到响应后更新为SUCCESS或FAILED。任务表本身有一个requestId唯一索引天然支持幂等。业务侧要支持幂等需要在接口层接收一个idempotencyKey。即使用户多次点击发送按钮同一个 key 只会触发一次真实请求。实现方式是在 Redis 里 set key requestId如果 key 已存在直接返回上一次的结果快照。这里有个注意点大模型的结果不是绝对幂等除非你把 temperature 设为 0 并且关闭随机采样否则同一个 prompt 两次调用的结果可能不同。所以幂等键更多是防“重复提交”而不是保证结果一致。如果确实需要结果一致性我会把模型返回结果在第一次成功时就缓存起来后续相同幂等键直接返回缓存值。这相当于业务层做了一层快照缓存适合支付类、合同生成类这种要求严格的场景。但对于日常聊天、内容生成直接做请求去重就够了。5. 核心模块代码实现精讲5.1 领域模型中的核心类领域层首先定义ChatCompletionGateway接口public interface ChatCompletionGateway { ChatCompletionResult chat(ChatCompletionCommand command); FluxStreamChunk chatStreaming(ChatCompletionCommand command); boolean supports(String modelName); }ChatCompletionCommand是一个包含所有请求参数的值对象public record ChatCompletionCommand( String requestId, String model, ListPromptMessage messages, GenerationParams generationParams, Boolean stream, ListToolDefinition tools ) {}PromptMessage也是一个 record包含role和contentrole用枚举限制为SYSTEM、USER、ASSISTANT、TOOL。这样在编译期就能防止拼错角色名。ChatCompletionResult则是包含messageId、content、finishReason、usage、toolCalls的领域对象。toolCalls是一个列表元素是ToolCall值对象包含toolCallId、toolName、argumentsJson。5.2 请求转换与响应解析基础设施层的核心类叫DeepSeekChatCompletionGateway它实现上面的接口。内部用一个DeepSeekRequestAssembler来把领域对象转换成 DeepSeek API 的请求结构public DeepSeekChatRequest toDeepSeekRequest(ChatCompletionCommand command) { return DeepSeekChatRequest.builder() .model(command.model()) .messages(command.messages().stream() .map(this::toDeepSeekMessage) .toList()) .maxTokens(command.generationParams().maxTokens()) .temperature(command.generationParams().temperature()) .stream(command.stream()) .tools(command.tools() ! null ? toDeepSeekTools(command.tools()) : null) .build(); }响应解析时特别留意流式数据。SSE 的每一行格式是data: {...}最后一行是data: [DONE]。WebClient 拿到的是一个字符串流需要自己切分。我写了一个SseParser工具类按行解析把[DONE]忽略掉只保留合法 JSON 行。实际还有一个小坑某些代理或网关会给响应增加额外的空行或 BOM 头直接ObjectMapper.readValue会报错。我处理时先trim()再用if (line.isBlank()) return;过一遍保证稳定性。5.3 工具调用的注册与调度工具调用是 Harness 项目里复杂度最高的部分。原版可能允许用户注册一堆 Python 函数Java 版同样可以做到。我设计了一个ToolRegistry用ConcurrentHashMapString, ToolHandler保存工具名和处理器。Component public class ToolRegistry { private final MapString, ToolHandler handlers new ConcurrentHashMap(); public void register(String name, ToolHandler handler) { handlers.put(name, handler); } public ToolInvocationResult execute(ToolCall toolCall) { ToolHandler handler handlers.get(toolCall.toolName()); if (handler null) { return ToolInvocationResult.failure(toolCall.toolCallId(), String.format(Unknown tool: %s, toolCall.toolName())); } try { Object output handler.execute(toolCall.argumentsJson()); return ToolInvocationResult.success(toolCall.toolCallId(), output); } catch (Exception e) { return ToolInvocationResult.failure(toolCall.toolCallId(), e.getMessage()); } } }这里最重要的是“工具调用结果必须回传给模型”。在 OpenAI 兼容协议里工具结果通常作为一个roletool的消息追加到消息列表里并且要带tool_call_id。如果不回传模型会进入死循环。这个逻辑放在应用层由continueWithToolResult方法完成。5.4 缓存与降级策略模型调用成本高能缓存就缓存。但不是所有请求都能缓存只有那些temperature0且max_tokens固定的请求才适合做结果缓存。我实现了一个CachedChatCompletionGateway装饰器实现了与原接口相同的接口如果命中缓存就直接返回。public class CachedChatCompletionGateway implements ChatCompletionGateway { private final ChatCompletionGateway delegate; private final CacheString, ChatCompletionResult cache; Override public ChatCompletionResult chat(ChatCompletionCommand command) { String key buildCacheKey(command); ChatCompletionResult cached cache.getIfPresent(key); if (cached ! null) { return cached; } ChatCompletionResult result delegate.chat(command); if (isCacheable(result)) { cache.put(key, result); } return result; } }缓存 key 的生成逻辑也要注意不能把时间戳放进去否则永远命中不了。一般是 model messages 内容 generationParams 的序列化值。如果消息里有动态变量可以在发送前用统一模板渲染后再拼 key。降级策略分为两层。第一层是 API 层面的熔断降级如果 DeepSeek 接口持续失败就快速失败返回一个预设的兜底文案避免用户长时间等待。第二层是业务层面的降级比如在流式场景里如果超过 15 秒没有收到第一个 chunk就直接切换成非流式请求。这个策略虽然笨但实测能明显提升用户体验。6. 测试策略与常见问题排查6.1 单元测试领域模型不依赖外部DDD 项目的好处是领域层可以非常轻量地进行单元测试。我测试Conversation这个聚合时完全不需要启动 Spring 容器。下面是一个典型测试Test void appendUserMessage_whenOverTokenLimit_shouldCompressHistory() { Conversation conversation Conversation.create(c1, GenerationParams.defaultParams()); conversation.appendUserMessage(Hello); conversation.appendAssistantMessage(Hi); conversation.truncate(5); assertTrue(conversation.getHistory().count() 2); }这里的truncate是一个领域方法内部根据ContextWindowSize判断需要丢弃哪些消息。把这些规则放在领域层写起测试来特别顺手不需要 mock 任何东西。对于值对象的校验测试我直接测构造参数异常。比如GenerationParams.of(2.5, 100)必须抛InvalidGenerationParamsException。这种测试能保证上层不会被非法参数污染。6.2 集成测试用 WireMock 模拟外部 API真正发请求到 DeepSeek 环境的测试不适合跑在每个开发者本地因为会消耗额度而且网络不稳定。我使用 WireMock 模拟一个假的 DeepSeek API 服务器并在测试配置里把 baseUrl 指向 mock 地址。SpringBootTest AutoConfigureWireMock(port 0) class DeepSeekChatCompletionGatewayTest { Autowired private ChatCompletionGateway gateway; Test void shouldParseCompletionResultWhenApiReturnsValidJson() { stubFor(post(urlEqualTo(/chat/completions)) .willReturn(okJson( { id: chatcmpl-123, choices: [ {message: {role: assistant, content: Hello!}} ] } ))); ChatCompletionResult result gateway.chat(buildCommand(say hello)); assertEquals(Hello!, result.getContent()); } }用 WireMock 还有一个好处可以模拟各种错误场景比如 500、429、超时然后验证重试和熔断是否按预期工作。如果不这么做生产环境遇到一次接口抖动就要抓瞎。6.3 常见问题与排查技巧实录问题一流式响应出现乱码或半条 JSON排查思路是不要在前端看直接看原始响应日志。SSE 是分块传输的读出来可能是一半要按行解析并且忽略不完整 JSON。解决方案是用StringDecoder按行分隔再对每一行做 JSON 解析。如果还是出现半行说明解码缓冲大小不够调大maxInMemorySize。问题二空闲连接被服务端断开大模型 API 的网关可能对空闲连接不友好。我的 WebClient 连接池默认没有做空闲清理导致长时间不调用后第一个请求会报连接重置。排查时看日志会有Connection reset by peer。解决办法是在连接池里配置evictInBackground和空闲检测或者每次请求前做一次健康检查。更简单的做法是把ChannelOption.CONNECT_TIMEOUT_MILLIS调短连接重置后重试一次就能恢复。问题三重试导致重复计费这是最坑的。某些模型 API 的计费是按 token 计算的你重试一次就多花一份钱。我的经验是只在拿到明确的 429 或 5xx 时才重试而且要对重试后的结果做去重处理。更严格的做法是重试时带上requestId让服务端知道这是同一请求。很多 API 支持idempotency头如果没有就得在自己的日志里加 traceId方便排查重复扣费。问题四上下文过大导致请求超时默认场景下会话历史越长每次请求的 token 数越多API 处理时间也会变长。如果发现响应延迟随对话轮数显著增加一定要检查上下文裁剪逻辑。我最终采用的策略是超过窗口后把最早的几条消息折叠成一个摘要段落而不是直接丢弃。这个摘要由模型生成成本可控而且能保留语义信息。7. 实操经验分享与后续扩展7.1 关于“1:1 复刻”的重新理解我一开始追求的是把原版 Harness 的每个接口、每个配置项都对齐。后来发现“1:1”并不需要死板到配置名都一样。更合理的理解是对外行为一致对内结构可以按 Java 生态的方式重构。比如原版可能是 Python 的 dataclass 和 dict 到处传Java 版就必须用 record、enum、泛型来约束这在“行为”上依然是 1:1但在类型安全上是增强。复刻过程中要特别注意“同步异步”问题。原版可能是同步的但 Java 生态里天然有 WebFlux如果硬要 1:1 同步到底反而会把系统的并发能力锁死。我最后的做法是 Gateway 接口同时提供同步和异步两个方法默认走同步但底层实现是同一个异步核心只是通过block()桥接。7.2 这套架构还能怎么扩展做完这个项目后我觉得最具扩展价值的是“多模型支持”。现在只实现了 DeepSeek 的 adapter但整个架构里ChatCompletionGateway已经定义得很干净。只要再加一个OpenAiChatCompletionGateway然后改一行工厂选择逻辑就能支持 OpenAI 兼容的其他服务。事实上很多开源模型网关都提供 OpenAI 兼容接口所以这个扩展成本非常低。另一个可以做的是把“工具调用”升级成“MCPModel Context Protocol”。MCP 协议本质上就是工具调用的标准化。现在我在ToolRegistry里用的是简单的 Map 注册后续可以抽象成 MCP client直接连远程工具服务器。这样 Harness 的作用就不只是包一层 API而是一个完整的智能体编排底座。还有一块是“审计与可观测性”。模型调用涉及敏感数据做审计日志非常重要。我在事件发布阶段已经留好了扩展点可以记录每次请求的 user id、conversation id、token 消耗、耗时、是否命中缓存。把这套数据接进 Prometheus 或 Grafana就能实时监控模型服务的使用情况。7.3 个人踩雷记录与最后建议最后分享几个我实际开发中遇到的小雷。第一不要在领域层引入 Lombok。虽然 Lombok 能省代码但 DDD 里的值对象和聚合根往往需要自定义构造逻辑和业务方法Lombok 的Data会把变异能力暴露出来破坏了不可变性。我全程用record和手写 Builder代码稍微多点但安全很多。第二WebClient 必须使用独立的 ObjectMapper。如果你默认 ObjectMapper 里注册了 JavaTimeModule 等扩展反序列化模型返回时可能对字符串字段产生不同处理导致结果和预期不符。最好为 DeepSeek API 单独准备一个 ObjectMapper 实例。第三重试里的 jitter 一定要开。没有抖动的话多个并发请求同时超时会同时开始重试形成一个小的“惊群”直接把 API 打挂。如果想用它做面试项目我的建议是把 DDD 建模过程中的决策点整理成一个设计文档。面试官通常不关心你用了什么注解更关心你为什么把ToolInvocation设计成聚合根、为什么ChatCompletionCommand是不可变对象、怎么处理外部 API 的防腐层。这些决策比代码本身更有说服力。这套代码已经从最初的第 0.0.1 版迭代到我现在正在用的版本中间踩过的坑都记录在上文。如果你想在自己项目里做一个类似的大模型集成层完全可以把这当作一份参考设计不用照抄只取合你业务场景的骨架就好。