Local branch had diverged from origin/master (sibling commits on the same base). Rewrote local history linearly on top of origin/master, folding in all local content: exa/hot-platform discovery hunters, leaks ledger, nightly sweep orchestrator, updated .gitignore and skill docs, plus the latest hunt output and vault state. Remote-only files (vault keys, channel scripts) were restored rather than dropped, so the resulting tree is a full union of both sides.
1 line
54 KiB
JSON
1 line
54 KiB
JSON
{"repo": "changweihua/changweihua.github.io", "files": {"zh-CN/blog/2026-07/SpringAI-DeepSeek.md": {"f057287706dd2228441ea67312e6effd27d2f5c0": {"t": 1785747145.3163717, "n": 5100, "h": "d044cb93d253276c", "c": "---\nlastUpdated: true\ncommentabled: true\nrecommended: true\ntitle: Spring AI 集成 DeepSeek 原生供应商\ndescription: 告别 OpenAI 兼容层,获取结构化推理过程\ndate: 2026-07-10 11:30:00\npageClass: blog-page-class\ncover: /covers/ai.svg\n---\n\n## 背景\n\n项目中一直用 Spring AI 的 OpenAI 兼容层调用 DeepSeek API(把 `spring.ai.openai.base-url` 指向 `https://api.deepseek.com`)。这种方式能跑,但有几个痛点:\n\n- 拿不到 `reasoning_content`:DeepSeek 的推理过程(CoT)不会以结构化字段返回,只能让模型把思考过程包在 `<think>` 标签里,前端再用状态机做标签解析,极其脆弱\n- 缺失 DeepSeek 特有 API:Prefix Completion、DeepSeek 特有的参数等都无法使用\n- 配置语义不清晰:写着 `openai`,实际调的却是 DeepSeek,维护成本高\n\nSpring AI 在 1.x 版本已经官方支持了 DeepSeek,本文记录完整的迁移过程。\n\n## 添加依赖\n\n在 `pom.xml` 中添加 DeepSeek Starter(版本由 BOM 1.1.3 统一管理):\n\n```xml\n<dependency>\n <groupId>org.springframework.ai</groupId>\n <artifactId>spring-ai-starter-model-deepseek</artifactId>\n</dependency>\n```\n\n## 配置供应商\n\n在 `application-dev.yml` 中添加 DeepSeek 配置块:\n\n```yaml\nspring:\n ai:\n deepseek:\n api-key: sk-xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx\n chat:\n options:\n model: deepseek-v4-flash\n temperature: 1.0\n```\n\nAPI Key 建议通过环境变量注入,避免硬编码。\n\n## 注册 Bean\n\n在 `AiConfig.java` 中注册 DeepSeek 的 ChatModel 和 ChatClient:\n\n```java\nimport org.springframework.ai.deepseek.DeepSeekChatModel;\n\n@Bean(\"deepseekChatModel\")\npublic ChatModel deepseekChatModel(DeepSeekChatModel deepSeekChatModel) {\n return deepSeekChatModel;\n}\n\n@Bean(\"deepseekChatClient\")\npublic ChatClient deepseekChatClient(\n @Qualifier(\"deepseekChatModel\") ChatModel deepseekChatModel,\n MessageFormatAdvisor messageFormatAdvisor,\n LifecycleToolCallAdvisor lifecycleToolCallAdvisor,\n TaskProgressAdvisor taskProgressAdvisor,\n RetryAdvisor retryAdvisor) {\n return ChatClient.builder(deepseekChatModel)\n .defaultToolContext(new HashMap<>(Map.of(\"debug\", true)))\n .defaultAdvisors(\n messageFormatAdvisor,\n lifecycleToolCallAdvisor,\n taskProgressAdvisor,\n retryAdvisor\n )\n .build();\n}\n```\n\n## Controller 改造 — 原生推理流式输出\n\n改造前:每个 SSE Chunk 拿到的是 `AssistantMessage`,`<think>` 标签可能被切碎在多个 `Chunk` 里,需要维护复杂的状态机做拼接。\n\n改造后:使用 `DeepSeekAssistantMessage`,`reasoningContent` 和 `text` 是两个独立字段:\n\n```java\nimport org.springframework.ai.deepseek.DeepSeekAssistantMessage;\n\n.concatMap(response -> {\n AssistantMessage output = response.getResult().getOutput();\n List<ServerSentEvent<ChatChunk>> events = new ArrayList<>();\n\n if (output.getToolCalls() != null && !output.getToolCalls().isEmpty()) {\n // handle tool calls\n return Flux.fromIterable(events);\n }\n\n if (output instanceof DeepSeekAssistantMessage dsMsg) {\n String reasoning = dsMsg.getReasoningContent();\n if (reasoning != null && !reasoning.isEmpty()) {\n state.accumulateReasoning(reasoning, events);\n }\n }\n\n String text = output.getText();\n if (text != null && !text.isEmpty()) {\n state.flushReasoning(events);\n events.add(createEvent(\"message\", state.messageId(), text, null));\n }\n\n return Flux.fromIterable(events);\n})\n```\n\n## 推理内容缓冲优化\n\n`reasoningContent` 以 Token 粒度到达,每个 SSE Chunk 可能只有一个字,直接推给前端会导致渲染碎片化。需要在服务端按语义边界缓冲:\n\n```java\nprivate static class StreamState {\n private static final int REASONING_FLUSH_THRESHOLD = 50;\n private static final Pattern SENTENCE_BOUNDARY =\n Pattern.compile(\"[。!?.!?\\n]+\");\n private final StringBuilder reasoningBuffer = new StringBuilder();\n\n public void accumulateReasoning(String delta,\n List<ServerSentEvent<ChatChunk>> target) {\n reasoningBuffer.append(delta);\n String buf = reasoningBuffer.toString();\n var matcher = SENTENCE_BOUNDARY.matcher(buf);\n int lastEnd = 0;\n while (matcher.find()) {\n String segment = buf.substring(lastEnd, matcher.end()).trim();\n if (!segment.isEmpty()) {\n target.add(createEvent(\"thought\", \"reasoning\", segment, null));\n }\n lastEnd = matcher.end();\n }\n reasoningBuffer.delete(0, lastEnd);\n if (reasoningBuffer.length() > REASONING_FLUSH_THRESHOLD) {\n target.add(createEvent(\"thought\", \"reasoning\",\n reasoningBuffer.toString(), null));\n reasoningBuffer.setLength(0);\n }\n }\n}\n```\n\n触发策略:\n\n- 遇到句号/问号/感叹号/换行 → 按标点切分,整句发出\n- 缓冲区积累超过 50 字符无标点 → 强制整块发出\n- 切换到文本输出或工具调用 → 排空缓存\n\n## 效果对比\n\n改造前:前端收到逐个单词的 `thought` 事件:\n\n```json\nevent: thought data: {\"content\":\"The\",\"role\":\"thought\"}\nevent: thought data: {\"content\":\"user\",\"role\":\"thought\"}\nevent: thought data: {\"content\":\"wants\",\"role\":\"thought\"}\n```\n\n改造后:前端收到完整的语义段落:\n\n```json\nevent: thought data: {\"content\":\"The user wants me to add a new feature.\",\"role\":\"thought\"}\nevent: thought data: {\"content\":\"Let me think about the best approach.\",\"role\":\"thought\"}\n```\n\n## 总结\n\nSpring AI 官方 DeepSeek Starter 带来的核心收益:\n\n- 结构化推理内容:`DeepSeekAssistantMessage.getReasoningContent()` 直接获取 CoT,无需 `<think>` 标签 hack\n- 服务端缓冲:按语义边界批量下发,前端零改动即可获得平滑渲染\n- 配置语义化:`spring.ai.deepseek.* `一目了然\n- 扩展性:无缝使用 DeepSeek 特有功能(Prefix Completion、Reasoning 多轮对话等)\n"}, "123fbcb638c7f48767f8f2da9168d1a61cdc53bf": {"t": 1785770706.144651, "n": 5100, "h": "d044cb93d253276c", "c": "---\nlastUpdated: true\ncommentabled: true\nrecommended: true\ntitle: Spring AI 集成 DeepSeek 原生供应商\ndescription: 告别 OpenAI 兼容层,获取结构化推理过程\ndate: 2026-07-10 11:30:00\npageClass: blog-page-class\ncover: /covers/ai.svg\n---\n\n## 背景\n\n项目中一直用 Spring AI 的 OpenAI 兼容层调用 DeepSeek API(把 `spring.ai.openai.base-url` 指向 `https://api.deepseek.com`)。这种方式能跑,但有几个痛点:\n\n- 拿不到 `reasoning_content`:DeepSeek 的推理过程(CoT)不会以结构化字段返回,只能让模型把思考过程包在 `<think>` 标签里,前端再用状态机做标签解析,极其脆弱\n- 缺失 DeepSeek 特有 API:Prefix Completion、DeepSeek 特有的参数等都无法使用\n- 配置语义不清晰:写着 `openai`,实际调的却是 DeepSeek,维护成本高\n\nSpring AI 在 1.x 版本已经官方支持了 DeepSeek,本文记录完整的迁移过程。\n\n## 添加依赖\n\n在 `pom.xml` 中添加 DeepSeek Starter(版本由 BOM 1.1.3 统一管理):\n\n```xml\n<dependency>\n <groupId>org.springframework.ai</groupId>\n <artifactId>spring-ai-starter-model-deepseek</artifactId>\n</dependency>\n```\n\n## 配置供应商\n\n在 `application-dev.yml` 中添加 DeepSeek 配置块:\n\n```yaml\nspring:\n ai:\n deepseek:\n api-key: sk-xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx\n chat:\n options:\n model: deepseek-v4-flash\n temperature: 1.0\n```\n\nAPI Key 建议通过环境变量注入,避免硬编码。\n\n## 注册 Bean\n\n在 `AiConfig.java` 中注册 DeepSeek 的 ChatModel 和 ChatClient:\n\n```java\nimport org.springframework.ai.deepseek.DeepSeekChatModel;\n\n@Bean(\"deepseekChatModel\")\npublic ChatModel deepseekChatModel(DeepSeekChatModel deepSeekChatModel) {\n return deepSeekChatModel;\n}\n\n@Bean(\"deepseekChatClient\")\npublic ChatClient deepseekChatClient(\n @Qualifier(\"deepseekChatModel\") ChatModel deepseekChatModel,\n MessageFormatAdvisor messageFormatAdvisor,\n LifecycleToolCallAdvisor lifecycleToolCallAdvisor,\n TaskProgressAdvisor taskProgressAdvisor,\n RetryAdvisor retryAdvisor) {\n return ChatClient.builder(deepseekChatModel)\n .defaultToolContext(new HashMap<>(Map.of(\"debug\", true)))\n .defaultAdvisors(\n messageFormatAdvisor,\n lifecycleToolCallAdvisor,\n taskProgressAdvisor,\n retryAdvisor\n )\n .build();\n}\n```\n\n## Controller 改造 — 原生推理流式输出\n\n改造前:每个 SSE Chunk 拿到的是 `AssistantMessage`,`<think>` 标签可能被切碎在多个 `Chunk` 里,需要维护复杂的状态机做拼接。\n\n改造后:使用 `DeepSeekAssistantMessage`,`reasoningContent` 和 `text` 是两个独立字段:\n\n```java\nimport org.springframework.ai.deepseek.DeepSeekAssistantMessage;\n\n.concatMap(response -> {\n AssistantMessage output = response.getResult().getOutput();\n List<ServerSentEvent<ChatChunk>> events = new ArrayList<>();\n\n if (output.getToolCalls() != null && !output.getToolCalls().isEmpty()) {\n // handle tool calls\n return Flux.fromIterable(events);\n }\n\n if (output instanceof DeepSeekAssistantMessage dsMsg) {\n String reasoning = dsMsg.getReasoningContent();\n if (reasoning != null && !reasoning.isEmpty()) {\n state.accumulateReasoning(reasoning, events);\n }\n }\n\n String text = output.getText();\n if (text != null && !text.isEmpty()) {\n state.flushReasoning(events);\n events.add(createEvent(\"message\", state.messageId(), text, null));\n }\n\n return Flux.fromIterable(events);\n})\n```\n\n## 推理内容缓冲优化\n\n`reasoningContent` 以 Token 粒度到达,每个 SSE Chunk 可能只有一个字,直接推给前端会导致渲染碎片化。需要在服务端按语义边界缓冲:\n\n```java\nprivate static class StreamState {\n private static final int REASONING_FLUSH_THRESHOLD = 50;\n private static final Pattern SENTENCE_BOUNDARY =\n Pattern.compile(\"[。!?.!?\\n]+\");\n private final StringBuilder reasoningBuffer = new StringBuilder();\n\n public void accumulateReasoning(String delta,\n List<ServerSentEvent<ChatChunk>> target) {\n reasoningBuffer.append(delta);\n String buf = reasoningBuffer.toString();\n var matcher = SENTENCE_BOUNDARY.matcher(buf);\n int lastEnd = 0;\n while (matcher.find()) {\n String segment = buf.substring(lastEnd, matcher.end()).trim();\n if (!segment.isEmpty()) {\n target.add(createEvent(\"thought\", \"reasoning\", segment, null));\n }\n lastEnd = matcher.end();\n }\n reasoningBuffer.delete(0, lastEnd);\n if (reasoningBuffer.length() > REASONING_FLUSH_THRESHOLD) {\n target.add(createEvent(\"thought\", \"reasoning\",\n reasoningBuffer.toString(), null));\n reasoningBuffer.setLength(0);\n }\n }\n}\n```\n\n触发策略:\n\n- 遇到句号/问号/感叹号/换行 → 按标点切分,整句发出\n- 缓冲区积累超过 50 字符无标点 → 强制整块发出\n- 切换到文本输出或工具调用 → 排空缓存\n\n## 效果对比\n\n改造前:前端收到逐个单词的 `thought` 事件:\n\n```json\nevent: thought data: {\"content\":\"The\",\"role\":\"thought\"}\nevent: thought data: {\"content\":\"user\",\"role\":\"thought\"}\nevent: thought data: {\"content\":\"wants\",\"role\":\"thought\"}\n```\n\n改造后:前端收到完整的语义段落:\n\n```json\nevent: thought data: {\"content\":\"The user wants me to add a new feature.\",\"role\":\"thought\"}\nevent: thought data: {\"content\":\"Let me think about the best approach.\",\"role\":\"thought\"}\n```\n\n## 总结\n\nSpring AI 官方 DeepSeek Starter 带来的核心收益:\n\n- 结构化推理内容:`DeepSeekAssistantMessage.getReasoningContent()` 直接获取 CoT,无需 `<think>` 标签 hack\n- 服务端缓冲:按语义边界批量下发,前端零改动即可获得平滑渲染\n- 配置语义化:`spring.ai.deepseek.* `一目了然\n- 扩展性:无缝使用 DeepSeek 特有功能(Prefix Completion、Reasoning 多轮对话等)\n"}, "f5042583395de5b11c8871ac1a4814d5e220b985": {"t": 1786068082.005876, "n": 5100, "h": "d044cb93d253276c", "c": "---\nlastUpdated: true\ncommentabled: true\nrecommended: true\ntitle: Spring AI 集成 DeepSeek 原生供应商\ndescription: 告别 OpenAI 兼容层,获取结构化推理过程\ndate: 2026-07-10 11:30:00\npageClass: blog-page-class\ncover: /covers/ai.svg\n---\n\n## 背景\n\n项目中一直用 Spring AI 的 OpenAI 兼容层调用 DeepSeek API(把 `spring.ai.openai.base-url` 指向 `https://api.deepseek.com`)。这种方式能跑,但有几个痛点:\n\n- 拿不到 `reasoning_content`:DeepSeek 的推理过程(CoT)不会以结构化字段返回,只能让模型把思考过程包在 `<think>` 标签里,前端再用状态机做标签解析,极其脆弱\n- 缺失 DeepSeek 特有 API:Prefix Completion、DeepSeek 特有的参数等都无法使用\n- 配置语义不清晰:写着 `openai`,实际调的却是 DeepSeek,维护成本高\n\nSpring AI 在 1.x 版本已经官方支持了 DeepSeek,本文记录完整的迁移过程。\n\n## 添加依赖\n\n在 `pom.xml` 中添加 DeepSeek Starter(版本由 BOM 1.1.3 统一管理):\n\n```xml\n<dependency>\n <groupId>org.springframework.ai</groupId>\n <artifactId>spring-ai-starter-model-deepseek</artifactId>\n</dependency>\n```\n\n## 配置供应商\n\n在 `application-dev.yml` 中添加 DeepSeek 配置块:\n\n```yaml\nspring:\n ai:\n deepseek:\n api-key: sk-xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx\n chat:\n options:\n model: deepseek-v4-flash\n temperature: 1.0\n```\n\nAPI Key 建议通过环境变量注入,避免硬编码。\n\n## 注册 Bean\n\n在 `AiConfig.java` 中注册 DeepSeek 的 ChatModel 和 ChatClient:\n\n```java\nimport org.springframework.ai.deepseek.DeepSeekChatModel;\n\n@Bean(\"deepseekChatModel\")\npublic ChatModel deepseekChatModel(DeepSeekChatModel deepSeekChatModel) {\n return deepSeekChatModel;\n}\n\n@Bean(\"deepseekChatClient\")\npublic ChatClient deepseekChatClient(\n @Qualifier(\"deepseekChatModel\") ChatModel deepseekChatModel,\n MessageFormatAdvisor messageFormatAdvisor,\n LifecycleToolCallAdvisor lifecycleToolCallAdvisor,\n TaskProgressAdvisor taskProgressAdvisor,\n RetryAdvisor retryAdvisor) {\n return ChatClient.builder(deepseekChatModel)\n .defaultToolContext(new HashMap<>(Map.of(\"debug\", true)))\n .defaultAdvisors(\n messageFormatAdvisor,\n lifecycleToolCallAdvisor,\n taskProgressAdvisor,\n retryAdvisor\n )\n .build();\n}\n```\n\n## Controller 改造 — 原生推理流式输出\n\n改造前:每个 SSE Chunk 拿到的是 `AssistantMessage`,`<think>` 标签可能被切碎在多个 `Chunk` 里,需要维护复杂的状态机做拼接。\n\n改造后:使用 `DeepSeekAssistantMessage`,`reasoningContent` 和 `text` 是两个独立字段:\n\n```java\nimport org.springframework.ai.deepseek.DeepSeekAssistantMessage;\n\n.concatMap(response -> {\n AssistantMessage output = response.getResult().getOutput();\n List<ServerSentEvent<ChatChunk>> events = new ArrayList<>();\n\n if (output.getToolCalls() != null && !output.getToolCalls().isEmpty()) {\n // handle tool calls\n return Flux.fromIterable(events);\n }\n\n if (output instanceof DeepSeekAssistantMessage dsMsg) {\n String reasoning = dsMsg.getReasoningContent();\n if (reasoning != null && !reasoning.isEmpty()) {\n state.accumulateReasoning(reasoning, events);\n }\n }\n\n String text = output.getText();\n if (text != null && !text.isEmpty()) {\n state.flushReasoning(events);\n events.add(createEvent(\"message\", state.messageId(), text, null));\n }\n\n return Flux.fromIterable(events);\n})\n```\n\n## 推理内容缓冲优化\n\n`reasoningContent` 以 Token 粒度到达,每个 SSE Chunk 可能只有一个字,直接推给前端会导致渲染碎片化。需要在服务端按语义边界缓冲:\n\n```java\nprivate static class StreamState {\n private static final int REASONING_FLUSH_THRESHOLD = 50;\n private static final Pattern SENTENCE_BOUNDARY =\n Pattern.compile(\"[。!?.!?\\n]+\");\n private final StringBuilder reasoningBuffer = new StringBuilder();\n\n public void accumulateReasoning(String delta,\n List<ServerSentEvent<ChatChunk>> target) {\n reasoningBuffer.append(delta);\n String buf = reasoningBuffer.toString();\n var matcher = SENTENCE_BOUNDARY.matcher(buf);\n int lastEnd = 0;\n while (matcher.find()) {\n String segment = buf.substring(lastEnd, matcher.end()).trim();\n if (!segment.isEmpty()) {\n target.add(createEvent(\"thought\", \"reasoning\", segment, null));\n }\n lastEnd = matcher.end();\n }\n reasoningBuffer.delete(0, lastEnd);\n if (reasoningBuffer.length() > REASONING_FLUSH_THRESHOLD) {\n target.add(createEvent(\"thought\", \"reasoning\",\n reasoningBuffer.toString(), null));\n reasoningBuffer.setLength(0);\n }\n }\n}\n```\n\n触发策略:\n\n- 遇到句号/问号/感叹号/换行 → 按标点切分,整句发出\n- 缓冲区积累超过 50 字符无标点 → 强制整块发出\n- 切换到文本输出或工具调用 → 排空缓存\n\n## 效果对比\n\n改造前:前端收到逐个单词的 `thought` 事件:\n\n```json\nevent: thought data: {\"content\":\"The\",\"role\":\"thought\"}\nevent: thought data: {\"content\":\"user\",\"role\":\"thought\"}\nevent: thought data: {\"content\":\"wants\",\"role\":\"thought\"}\n```\n\n改造后:前端收到完整的语义段落:\n\n```json\nevent: thought data: {\"content\":\"The user wants me to add a new feature.\",\"role\":\"thought\"}\nevent: thought data: {\"content\":\"Let me think about the best approach.\",\"role\":\"thought\"}\n```\n\n## 总结\n\nSpring AI 官方 DeepSeek Starter 带来的核心收益:\n\n- 结构化推理内容:`DeepSeekAssistantMessage.getReasoningContent()` 直接获取 CoT,无需 `<think>` 标签 hack\n- 服务端缓冲:按语义边界批量下发,前端零改动即可获得平滑渲染\n- 配置语义化:`spring.ai.deepseek.* `一目了然\n- 扩展性:无缝使用 DeepSeek 特有功能(Prefix Completion、Reasoning 多轮对话等)\n"}, "af76045945c2f1baee35e9f465f60d39061672d6": {"t": 1786068549.9920926, "n": 5100, "h": "d044cb93d253276c", "c": "---\nlastUpdated: true\ncommentabled: true\nrecommended: true\ntitle: Spring AI 集成 DeepSeek 原生供应商\ndescription: 告别 OpenAI 兼容层,获取结构化推理过程\ndate: 2026-07-10 11:30:00\npageClass: blog-page-class\ncover: /covers/ai.svg\n---\n\n## 背景\n\n项目中一直用 Spring AI 的 OpenAI 兼容层调用 DeepSeek API(把 `spring.ai.openai.base-url` 指向 `https://api.deepseek.com`)。这种方式能跑,但有几个痛点:\n\n- 拿不到 `reasoning_content`:DeepSeek 的推理过程(CoT)不会以结构化字段返回,只能让模型把思考过程包在 `<think>` 标签里,前端再用状态机做标签解析,极其脆弱\n- 缺失 DeepSeek 特有 API:Prefix Completion、DeepSeek 特有的参数等都无法使用\n- 配置语义不清晰:写着 `openai`,实际调的却是 DeepSeek,维护成本高\n\nSpring AI 在 1.x 版本已经官方支持了 DeepSeek,本文记录完整的迁移过程。\n\n## 添加依赖\n\n在 `pom.xml` 中添加 DeepSeek Starter(版本由 BOM 1.1.3 统一管理):\n\n```xml\n<dependency>\n <groupId>org.springframework.ai</groupId>\n <artifactId>spring-ai-starter-model-deepseek</artifactId>\n</dependency>\n```\n\n## 配置供应商\n\n在 `application-dev.yml` 中添加 DeepSeek 配置块:\n\n```yaml\nspring:\n ai:\n deepseek:\n api-key: sk-xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx\n chat:\n options:\n model: deepseek-v4-flash\n temperature: 1.0\n```\n\nAPI Key 建议通过环境变量注入,避免硬编码。\n\n## 注册 Bean\n\n在 `AiConfig.java` 中注册 DeepSeek 的 ChatModel 和 ChatClient:\n\n```java\nimport org.springframework.ai.deepseek.DeepSeekChatModel;\n\n@Bean(\"deepseekChatModel\")\npublic ChatModel deepseekChatModel(DeepSeekChatModel deepSeekChatModel) {\n return deepSeekChatModel;\n}\n\n@Bean(\"deepseekChatClient\")\npublic ChatClient deepseekChatClient(\n @Qualifier(\"deepseekChatModel\") ChatModel deepseekChatModel,\n MessageFormatAdvisor messageFormatAdvisor,\n LifecycleToolCallAdvisor lifecycleToolCallAdvisor,\n TaskProgressAdvisor taskProgressAdvisor,\n RetryAdvisor retryAdvisor) {\n return ChatClient.builder(deepseekChatModel)\n .defaultToolContext(new HashMap<>(Map.of(\"debug\", true)))\n .defaultAdvisors(\n messageFormatAdvisor,\n lifecycleToolCallAdvisor,\n taskProgressAdvisor,\n retryAdvisor\n )\n .build();\n}\n```\n\n## Controller 改造 — 原生推理流式输出\n\n改造前:每个 SSE Chunk 拿到的是 `AssistantMessage`,`<think>` 标签可能被切碎在多个 `Chunk` 里,需要维护复杂的状态机做拼接。\n\n改造后:使用 `DeepSeekAssistantMessage`,`reasoningContent` 和 `text` 是两个独立字段:\n\n```java\nimport org.springframework.ai.deepseek.DeepSeekAssistantMessage;\n\n.concatMap(response -> {\n AssistantMessage output = response.getResult().getOutput();\n List<ServerSentEvent<ChatChunk>> events = new ArrayList<>();\n\n if (output.getToolCalls() != null && !output.getToolCalls().isEmpty()) {\n // handle tool calls\n return Flux.fromIterable(events);\n }\n\n if (output instanceof DeepSeekAssistantMessage dsMsg) {\n String reasoning = dsMsg.getReasoningContent();\n if (reasoning != null && !reasoning.isEmpty()) {\n state.accumulateReasoning(reasoning, events);\n }\n }\n\n String text = output.getText();\n if (text != null && !text.isEmpty()) {\n state.flushReasoning(events);\n events.add(createEvent(\"message\", state.messageId(), text, null));\n }\n\n return Flux.fromIterable(events);\n})\n```\n\n## 推理内容缓冲优化\n\n`reasoningContent` 以 Token 粒度到达,每个 SSE Chunk 可能只有一个字,直接推给前端会导致渲染碎片化。需要在服务端按语义边界缓冲:\n\n```java\nprivate static class StreamState {\n private static final int REASONING_FLUSH_THRESHOLD = 50;\n private static final Pattern SENTENCE_BOUNDARY =\n Pattern.compile(\"[。!?.!?\\n]+\");\n private final StringBuilder reasoningBuffer = new StringBuilder();\n\n public void accumulateReasoning(String delta,\n List<ServerSentEvent<ChatChunk>> target) {\n reasoningBuffer.append(delta);\n String buf = reasoningBuffer.toString();\n var matcher = SENTENCE_BOUNDARY.matcher(buf);\n int lastEnd = 0;\n while (matcher.find()) {\n String segment = buf.substring(lastEnd, matcher.end()).trim();\n if (!segment.isEmpty()) {\n target.add(createEvent(\"thought\", \"reasoning\", segment, null));\n }\n lastEnd = matcher.end();\n }\n reasoningBuffer.delete(0, lastEnd);\n if (reasoningBuffer.length() > REASONING_FLUSH_THRESHOLD) {\n target.add(createEvent(\"thought\", \"reasoning\",\n reasoningBuffer.toString(), null));\n reasoningBuffer.setLength(0);\n }\n }\n}\n```\n\n触发策略:\n\n- 遇到句号/问号/感叹号/换行 → 按标点切分,整句发出\n- 缓冲区积累超过 50 字符无标点 → 强制整块发出\n- 切换到文本输出或工具调用 → 排空缓存\n\n## 效果对比\n\n改造前:前端收到逐个单词的 `thought` 事件:\n\n```json\nevent: thought data: {\"content\":\"The\",\"role\":\"thought\"}\nevent: thought data: {\"content\":\"user\",\"role\":\"thought\"}\nevent: thought data: {\"content\":\"wants\",\"role\":\"thought\"}\n```\n\n改造后:前端收到完整的语义段落:\n\n```json\nevent: thought data: {\"content\":\"The user wants me to add a new feature.\",\"role\":\"thought\"}\nevent: thought data: {\"content\":\"Let me think about the best approach.\",\"role\":\"thought\"}\n```\n\n## 总结\n\nSpring AI 官方 DeepSeek Starter 带来的核心收益:\n\n- 结构化推理内容:`DeepSeekAssistantMessage.getReasoningContent()` 直接获取 CoT,无需 `<think>` 标签 hack\n- 服务端缓冲:按语义边界批量下发,前端零改动即可获得平滑渲染\n- 配置语义化:`spring.ai.deepseek.* `一目了然\n- 扩展性:无缝使用 DeepSeek 特有功能(Prefix Completion、Reasoning 多轮对话等)\n"}}, "zh-CN/blog/2026-07/Spring-AI-DeepSeek.md": {"f057287706dd2228441ea67312e6effd27d2f5c0": {"t": 1785747145.3877273, "n": 5482, "h": "4c4097ce93c05a35", "c": "---\nlastUpdated: true\ncommentabled: true\nrecommended: true\ntitle: Spring AI 集成 DeepSeek 原生供应商\ndescription: 实现think模式\ndate: 2026-07-22 10:55:00\npageClass: blog-page-class\ncover: /covers/react.svg\n---\n\n## 背景\n\n项目中一直用 Spring AI 的 OpenAI 兼容层调用 DeepSeek API(把 `spring.ai.openai.base-url` 指向 `https://api.deepseek.com`)。这种方式能跑,但有几个痛点:\n\n- 拿不到 `reasoning_content`:DeepSeek 的推理过程(CoT)不会以结构化字段返回,只能让模型把思考过程包在 `<think>` 标签里,前端再用状态机做标签解析,极其脆弱\n- 缺失 DeepSeek 特有 API:Prefix Completion、DeepSeek 特有的参数等都无法使用\n- 语义不清晰:配置里写着 openai,实际调的却是 DeepSeek,维护成本高\n\nSpring AI 在 2.x 版本已经官方支持了 DeepSeek,本文记录完整的迁移过程。\n\n## 添加依赖\n\n在 `pom.xml` 中添加 DeepSeek Starter(版本由 BOM 2.0.0 统一管理):\n\n```xml\n<dependency>\n <groupId>org.springframework.ai</groupId>\n <artifactId>spring-ai-starter-model-deepseek</artifactId>\n</dependency>\n```\n\n## 配置供应商\n\n在 `application-dev.yml` 中添加 DeepSeek 配置块:\n\n```yaml\nspring:\n ai:\n deepseek:\n api-key: sk-xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx\n chat:\n options:\n model: deepseek-v4-flash\n temperature: 1.0\n```\n\nAPI Key 建议通过环境变量注入,避免硬编码。\n\n## 注册 Bean\n\n在 `AiConfig.java` 中注册 DeepSeek 的 ChatModel 和 ChatClient:\n\n```java\nimport org.springframework.ai.deepseek.DeepSeekChatModel;\n\n@Bean(\"deepseekChatModel\")\npublic ChatModel deepseekChatModel(DeepSeekChatModel deepSeekChatModel) {\n return deepSeekChatModel;\n}\n\n@Bean(\"deepseekChatClient\")\npublic ChatClient deepseekChatClient(\n @Qualifier(\"deepseekChatModel\") ChatModel deepseekChatModel,\n MessageFormatAdvisor messageFormatAdvisor,\n LifecycleToolCallAdvisor lifecycleToolCallAdvisor,\n TaskProgressAdvisor taskProgressAdvisor,\n RetryAdvisor retryAdvisor) {\n return ChatClient.builder(deepseekChatModel)\n .defaultToolContext(new HashMap<>(Map.of(\"debug\", true)))\n .defaultAdvisors(\n messageFormatAdvisor,\n lifecycleToolCallAdvisor,\n taskProgressAdvisor,\n retryAdvisor\n )\n .build();\n}\n```\n\n## Controller 改造 — 原生推理流式输出\n\n_改造前的痛点_:每个 SSE Chunk 拿到的是 AssistantMessage,`<think>` 标签可能被切碎在多个 Chunk 里,需要维护复杂的状态机做拼接。\n\n_改造后_:使用 `DeepSeekAssistantMessage`,`reasoningContent` 和 `text` 是两个独立的字段:\n\n```java\nimport org.springframework.ai.deepseek.DeepSeekAssistantMessage;\n\n// 流式处理核心逻辑\n.concatMap(response -> {\n AssistantMessage output = response.getResult().getOutput();\n List<ServerSentEvent<ChatChunk>> events = new ArrayList<>();\n\n // 工具调用\n if (output.getToolCalls() != null && !output.getToolCalls().isEmpty()) {\n // ... handle tool calls\n return Flux.fromIterable(events);\n }\n\n // DeepSeek 原生推理内容\n if (output instanceof DeepSeekAssistantMessage dsMsg) {\n String reasoning = dsMsg.getReasoningContent();\n if (reasoning != null && !reasoning.isEmpty()) {\n // 缓冲后发送(见下文)\n state.accumulateReasoning(reasoning, events);\n }\n }\n\n // 文本内容\n String text = output.getText();\n if (text != null && !text.isEmpty()) {\n state.flushReasoning(events);\n events.add(createEvent(\"message\", state.messageId(), text, null));\n }\n\n return Flux.fromIterable(events);\n})\n```\n\n## 推理内容缓冲优化\n\n`reasoningContent` 以 Token 粒度到达,每个 SSE Chunk 可能只有一个字/词,直接推给前端会导致渲染碎片化。需要在服务端做缓冲,按语义边界批量下发。\n\n核心实现:累积推理内容到 StringBuilder,遇到句子结束标点时切分发出:\n\n```java\nprivate static class StreamState {\n private static final int REASONING_FLUSH_THRESHOLD = 50;\n private static final Pattern SENTENCE_BOUNDARY =\n Pattern.compile(\"[。!?.!?\\\\n]+\");\n\n private final StringBuilder reasoningBuffer = new StringBuilder();\n\n public void accumulateReasoning(String delta,\n List<ServerSentEvent<ChatChunk>> target) {\n reasoningBuffer.append(delta);\n\n // 按标点切分,整句发出\n String buf = reasoningBuffer.toString();\n var matcher = SENTENCE_BOUNDARY.matcher(buf);\n int lastEnd = 0;\n while (matcher.find()) {\n String segment = buf.substring(lastEnd, matcher.end()).trim();\n if (!segment.isEmpty()) {\n target.add(createEvent(\"thought\", \"reasoning\", segment, null));\n }\n lastEnd = matcher.end();\n }\n reasoningBuffer.delete(0, lastEnd);\n\n // 无标点时强制 flush(避免长思考无反馈)\n if (reasoningBuffer.length() > REASONING_FLUSH_THRESHOLD) {\n String forced = reasoningBuffer.toString();\n reasoningBuffer.setLength(0);\n target.add(createEvent(\"thought\", \"reasoning\", forced, null));\n }\n }\n}\n```\n\n触发策略:\n\n| 场景 | 行为 |\n| :--------------------------- | :------------------- |\n| 遇到句号/问号/感叹号/换行 | 按标点切分,整句发出 |\n| 缓冲区积累超过 50 字符无标点 | 强制整块发出 |\n| 切换到文本输出或工具调用 | 排空缓存 |\n\n## 效果对比\n\n改造前: 前端收到的是逐个单词的 `thought` 事件,需要前端做拼接渲染:\n\n```json\nevent: thought\ndata: {\"content\":\"The\", \"role\":\"thought\"}\nevent: thought\ndata: {\"content\":\"user\", \"role\":\"thought\"}\nevent: thought\ndata: {\"content\":\"wants\", \"role\":\"thought\"}\n```\n\n改造后: 前端收到完整的语义段落,直接展示:\n\n```json\nevent: thought\ndata: {\"content\":\"The user wants me to add a new feature.\", \"role\":\"thought\"}\nevent: thought\ndata: {\"content\":\"Let me think about the best approach.\", \"role\":\"thought\"}\n```\n\n## 总结\n\nSpring AI 官方 DeepSeek Starter 带来的核心收益:\n\n- 结构化推理内容:`DeepSeekAssistantMessage.getReasoningContent()` 直接获取 CoT,无需 `<think>` 标签 hack\n- 服务端缓冲:按语义边界批量下发,前端零改动即可获得平滑渲染\n- 配置语义化:配置即文档,`spring.ai.deepseek.*` 一目了然\n- 扩展性:后续可以无缝使用 DeepSeek 特有功能(Prefix Completion、Reasoning 多轮对话等)\n"}, "13282d559c6731d5ffb7ee3f7703f143647d37b8": {"t": 1785770706.3201127, "n": 5482, "h": "4c4097ce93c05a35", "c": "---\nlastUpdated: true\ncommentabled: true\nrecommended: true\ntitle: Spring AI 集成 DeepSeek 原生供应商\ndescription: 实现think模式\ndate: 2026-07-22 10:55:00\npageClass: blog-page-class\ncover: /covers/react.svg\n---\n\n## 背景\n\n项目中一直用 Spring AI 的 OpenAI 兼容层调用 DeepSeek API(把 `spring.ai.openai.base-url` 指向 `https://api.deepseek.com`)。这种方式能跑,但有几个痛点:\n\n- 拿不到 `reasoning_content`:DeepSeek 的推理过程(CoT)不会以结构化字段返回,只能让模型把思考过程包在 `<think>` 标签里,前端再用状态机做标签解析,极其脆弱\n- 缺失 DeepSeek 特有 API:Prefix Completion、DeepSeek 特有的参数等都无法使用\n- 语义不清晰:配置里写着 openai,实际调的却是 DeepSeek,维护成本高\n\nSpring AI 在 2.x 版本已经官方支持了 DeepSeek,本文记录完整的迁移过程。\n\n## 添加依赖\n\n在 `pom.xml` 中添加 DeepSeek Starter(版本由 BOM 2.0.0 统一管理):\n\n```xml\n<dependency>\n <groupId>org.springframework.ai</groupId>\n <artifactId>spring-ai-starter-model-deepseek</artifactId>\n</dependency>\n```\n\n## 配置供应商\n\n在 `application-dev.yml` 中添加 DeepSeek 配置块:\n\n```yaml\nspring:\n ai:\n deepseek:\n api-key: sk-xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx\n chat:\n options:\n model: deepseek-v4-flash\n temperature: 1.0\n```\n\nAPI Key 建议通过环境变量注入,避免硬编码。\n\n## 注册 Bean\n\n在 `AiConfig.java` 中注册 DeepSeek 的 ChatModel 和 ChatClient:\n\n```java\nimport org.springframework.ai.deepseek.DeepSeekChatModel;\n\n@Bean(\"deepseekChatModel\")\npublic ChatModel deepseekChatModel(DeepSeekChatModel deepSeekChatModel) {\n return deepSeekChatModel;\n}\n\n@Bean(\"deepseekChatClient\")\npublic ChatClient deepseekChatClient(\n @Qualifier(\"deepseekChatModel\") ChatModel deepseekChatModel,\n MessageFormatAdvisor messageFormatAdvisor,\n LifecycleToolCallAdvisor lifecycleToolCallAdvisor,\n TaskProgressAdvisor taskProgressAdvisor,\n RetryAdvisor retryAdvisor) {\n return ChatClient.builder(deepseekChatModel)\n .defaultToolContext(new HashMap<>(Map.of(\"debug\", true)))\n .defaultAdvisors(\n messageFormatAdvisor,\n lifecycleToolCallAdvisor,\n taskProgressAdvisor,\n retryAdvisor\n )\n .build();\n}\n```\n\n## Controller 改造 — 原生推理流式输出\n\n_改造前的痛点_:每个 SSE Chunk 拿到的是 AssistantMessage,`<think>` 标签可能被切碎在多个 Chunk 里,需要维护复杂的状态机做拼接。\n\n_改造后_:使用 `DeepSeekAssistantMessage`,`reasoningContent` 和 `text` 是两个独立的字段:\n\n```java\nimport org.springframework.ai.deepseek.DeepSeekAssistantMessage;\n\n// 流式处理核心逻辑\n.concatMap(response -> {\n AssistantMessage output = response.getResult().getOutput();\n List<ServerSentEvent<ChatChunk>> events = new ArrayList<>();\n\n // 工具调用\n if (output.getToolCalls() != null && !output.getToolCalls().isEmpty()) {\n // ... handle tool calls\n return Flux.fromIterable(events);\n }\n\n // DeepSeek 原生推理内容\n if (output instanceof DeepSeekAssistantMessage dsMsg) {\n String reasoning = dsMsg.getReasoningContent();\n if (reasoning != null && !reasoning.isEmpty()) {\n // 缓冲后发送(见下文)\n state.accumulateReasoning(reasoning, events);\n }\n }\n\n // 文本内容\n String text = output.getText();\n if (text != null && !text.isEmpty()) {\n state.flushReasoning(events);\n events.add(createEvent(\"message\", state.messageId(), text, null));\n }\n\n return Flux.fromIterable(events);\n})\n```\n\n## 推理内容缓冲优化\n\n`reasoningContent` 以 Token 粒度到达,每个 SSE Chunk 可能只有一个字/词,直接推给前端会导致渲染碎片化。需要在服务端做缓冲,按语义边界批量下发。\n\n核心实现:累积推理内容到 StringBuilder,遇到句子结束标点时切分发出:\n\n```java\nprivate static class StreamState {\n private static final int REASONING_FLUSH_THRESHOLD = 50;\n private static final Pattern SENTENCE_BOUNDARY =\n Pattern.compile(\"[。!?.!?\\\\n]+\");\n\n private final StringBuilder reasoningBuffer = new StringBuilder();\n\n public void accumulateReasoning(String delta,\n List<ServerSentEvent<ChatChunk>> target) {\n reasoningBuffer.append(delta);\n\n // 按标点切分,整句发出\n String buf = reasoningBuffer.toString();\n var matcher = SENTENCE_BOUNDARY.matcher(buf);\n int lastEnd = 0;\n while (matcher.find()) {\n String segment = buf.substring(lastEnd, matcher.end()).trim();\n if (!segment.isEmpty()) {\n target.add(createEvent(\"thought\", \"reasoning\", segment, null));\n }\n lastEnd = matcher.end();\n }\n reasoningBuffer.delete(0, lastEnd);\n\n // 无标点时强制 flush(避免长思考无反馈)\n if (reasoningBuffer.length() > REASONING_FLUSH_THRESHOLD) {\n String forced = reasoningBuffer.toString();\n reasoningBuffer.setLength(0);\n target.add(createEvent(\"thought\", \"reasoning\", forced, null));\n }\n }\n}\n```\n\n触发策略:\n\n| 场景 | 行为 |\n| :--------------------------- | :------------------- |\n| 遇到句号/问号/感叹号/换行 | 按标点切分,整句发出 |\n| 缓冲区积累超过 50 字符无标点 | 强制整块发出 |\n| 切换到文本输出或工具调用 | 排空缓存 |\n\n## 效果对比\n\n改造前: 前端收到的是逐个单词的 `thought` 事件,需要前端做拼接渲染:\n\n```json\nevent: thought\ndata: {\"content\":\"The\", \"role\":\"thought\"}\nevent: thought\ndata: {\"content\":\"user\", \"role\":\"thought\"}\nevent: thought\ndata: {\"content\":\"wants\", \"role\":\"thought\"}\n```\n\n改造后: 前端收到完整的语义段落,直接展示:\n\n```json\nevent: thought\ndata: {\"content\":\"The user wants me to add a new feature.\", \"role\":\"thought\"}\nevent: thought\ndata: {\"content\":\"Let me think about the best approach.\", \"role\":\"thought\"}\n```\n\n## 总结\n\nSpring AI 官方 DeepSeek Starter 带来的核心收益:\n\n- 结构化推理内容:`DeepSeekAssistantMessage.getReasoningContent()` 直接获取 CoT,无需 `<think>` 标签 hack\n- 服务端缓冲:按语义边界批量下发,前端零改动即可获得平滑渲染\n- 配置语义化:配置即文档,`spring.ai.deepseek.*` 一目了然\n- 扩展性:后续可以无缝使用 DeepSeek 特有功能(Prefix Completion、Reasoning 多轮对话等)\n"}, "f5042583395de5b11c8871ac1a4814d5e220b985": {"t": 1786068082.0849156, "n": 5482, "h": "4c4097ce93c05a35", "c": "---\nlastUpdated: true\ncommentabled: true\nrecommended: true\ntitle: Spring AI 集成 DeepSeek 原生供应商\ndescription: 实现think模式\ndate: 2026-07-22 10:55:00\npageClass: blog-page-class\ncover: /covers/react.svg\n---\n\n## 背景\n\n项目中一直用 Spring AI 的 OpenAI 兼容层调用 DeepSeek API(把 `spring.ai.openai.base-url` 指向 `https://api.deepseek.com`)。这种方式能跑,但有几个痛点:\n\n- 拿不到 `reasoning_content`:DeepSeek 的推理过程(CoT)不会以结构化字段返回,只能让模型把思考过程包在 `<think>` 标签里,前端再用状态机做标签解析,极其脆弱\n- 缺失 DeepSeek 特有 API:Prefix Completion、DeepSeek 特有的参数等都无法使用\n- 语义不清晰:配置里写着 openai,实际调的却是 DeepSeek,维护成本高\n\nSpring AI 在 2.x 版本已经官方支持了 DeepSeek,本文记录完整的迁移过程。\n\n## 添加依赖\n\n在 `pom.xml` 中添加 DeepSeek Starter(版本由 BOM 2.0.0 统一管理):\n\n```xml\n<dependency>\n <groupId>org.springframework.ai</groupId>\n <artifactId>spring-ai-starter-model-deepseek</artifactId>\n</dependency>\n```\n\n## 配置供应商\n\n在 `application-dev.yml` 中添加 DeepSeek 配置块:\n\n```yaml\nspring:\n ai:\n deepseek:\n api-key: sk-xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx\n chat:\n options:\n model: deepseek-v4-flash\n temperature: 1.0\n```\n\nAPI Key 建议通过环境变量注入,避免硬编码。\n\n## 注册 Bean\n\n在 `AiConfig.java` 中注册 DeepSeek 的 ChatModel 和 ChatClient:\n\n```java\nimport org.springframework.ai.deepseek.DeepSeekChatModel;\n\n@Bean(\"deepseekChatModel\")\npublic ChatModel deepseekChatModel(DeepSeekChatModel deepSeekChatModel) {\n return deepSeekChatModel;\n}\n\n@Bean(\"deepseekChatClient\")\npublic ChatClient deepseekChatClient(\n @Qualifier(\"deepseekChatModel\") ChatModel deepseekChatModel,\n MessageFormatAdvisor messageFormatAdvisor,\n LifecycleToolCallAdvisor lifecycleToolCallAdvisor,\n TaskProgressAdvisor taskProgressAdvisor,\n RetryAdvisor retryAdvisor) {\n return ChatClient.builder(deepseekChatModel)\n .defaultToolContext(new HashMap<>(Map.of(\"debug\", true)))\n .defaultAdvisors(\n messageFormatAdvisor,\n lifecycleToolCallAdvisor,\n taskProgressAdvisor,\n retryAdvisor\n )\n .build();\n}\n```\n\n## Controller 改造 — 原生推理流式输出\n\n_改造前的痛点_:每个 SSE Chunk 拿到的是 AssistantMessage,`<think>` 标签可能被切碎在多个 Chunk 里,需要维护复杂的状态机做拼接。\n\n_改造后_:使用 `DeepSeekAssistantMessage`,`reasoningContent` 和 `text` 是两个独立的字段:\n\n```java\nimport org.springframework.ai.deepseek.DeepSeekAssistantMessage;\n\n// 流式处理核心逻辑\n.concatMap(response -> {\n AssistantMessage output = response.getResult().getOutput();\n List<ServerSentEvent<ChatChunk>> events = new ArrayList<>();\n\n // 工具调用\n if (output.getToolCalls() != null && !output.getToolCalls().isEmpty()) {\n // ... handle tool calls\n return Flux.fromIterable(events);\n }\n\n // DeepSeek 原生推理内容\n if (output instanceof DeepSeekAssistantMessage dsMsg) {\n String reasoning = dsMsg.getReasoningContent();\n if (reasoning != null && !reasoning.isEmpty()) {\n // 缓冲后发送(见下文)\n state.accumulateReasoning(reasoning, events);\n }\n }\n\n // 文本内容\n String text = output.getText();\n if (text != null && !text.isEmpty()) {\n state.flushReasoning(events);\n events.add(createEvent(\"message\", state.messageId(), text, null));\n }\n\n return Flux.fromIterable(events);\n})\n```\n\n## 推理内容缓冲优化\n\n`reasoningContent` 以 Token 粒度到达,每个 SSE Chunk 可能只有一个字/词,直接推给前端会导致渲染碎片化。需要在服务端做缓冲,按语义边界批量下发。\n\n核心实现:累积推理内容到 StringBuilder,遇到句子结束标点时切分发出:\n\n```java\nprivate static class StreamState {\n private static final int REASONING_FLUSH_THRESHOLD = 50;\n private static final Pattern SENTENCE_BOUNDARY =\n Pattern.compile(\"[。!?.!?\\\\n]+\");\n\n private final StringBuilder reasoningBuffer = new StringBuilder();\n\n public void accumulateReasoning(String delta,\n List<ServerSentEvent<ChatChunk>> target) {\n reasoningBuffer.append(delta);\n\n // 按标点切分,整句发出\n String buf = reasoningBuffer.toString();\n var matcher = SENTENCE_BOUNDARY.matcher(buf);\n int lastEnd = 0;\n while (matcher.find()) {\n String segment = buf.substring(lastEnd, matcher.end()).trim();\n if (!segment.isEmpty()) {\n target.add(createEvent(\"thought\", \"reasoning\", segment, null));\n }\n lastEnd = matcher.end();\n }\n reasoningBuffer.delete(0, lastEnd);\n\n // 无标点时强制 flush(避免长思考无反馈)\n if (reasoningBuffer.length() > REASONING_FLUSH_THRESHOLD) {\n String forced = reasoningBuffer.toString();\n reasoningBuffer.setLength(0);\n target.add(createEvent(\"thought\", \"reasoning\", forced, null));\n }\n }\n}\n```\n\n触发策略:\n\n| 场景 | 行为 |\n| :--------------------------- | :------------------- |\n| 遇到句号/问号/感叹号/换行 | 按标点切分,整句发出 |\n| 缓冲区积累超过 50 字符无标点 | 强制整块发出 |\n| 切换到文本输出或工具调用 | 排空缓存 |\n\n## 效果对比\n\n改造前: 前端收到的是逐个单词的 `thought` 事件,需要前端做拼接渲染:\n\n```json\nevent: thought\ndata: {\"content\":\"The\", \"role\":\"thought\"}\nevent: thought\ndata: {\"content\":\"user\", \"role\":\"thought\"}\nevent: thought\ndata: {\"content\":\"wants\", \"role\":\"thought\"}\n```\n\n改造后: 前端收到完整的语义段落,直接展示:\n\n```json\nevent: thought\ndata: {\"content\":\"The user wants me to add a new feature.\", \"role\":\"thought\"}\nevent: thought\ndata: {\"content\":\"Let me think about the best approach.\", \"role\":\"thought\"}\n```\n\n## 总结\n\nSpring AI 官方 DeepSeek Starter 带来的核心收益:\n\n- 结构化推理内容:`DeepSeekAssistantMessage.getReasoningContent()` 直接获取 CoT,无需 `<think>` 标签 hack\n- 服务端缓冲:按语义边界批量下发,前端零改动即可获得平滑渲染\n- 配置语义化:配置即文档,`spring.ai.deepseek.*` 一目了然\n- 扩展性:后续可以无缝使用 DeepSeek 特有功能(Prefix Completion、Reasoning 多轮对话等)\n"}, "af76045945c2f1baee35e9f465f60d39061672d6": {"t": 1786068550.2051992, "n": 5482, "h": "4c4097ce93c05a35", "c": "---\nlastUpdated: true\ncommentabled: true\nrecommended: true\ntitle: Spring AI 集成 DeepSeek 原生供应商\ndescription: 实现think模式\ndate: 2026-07-22 10:55:00\npageClass: blog-page-class\ncover: /covers/react.svg\n---\n\n## 背景\n\n项目中一直用 Spring AI 的 OpenAI 兼容层调用 DeepSeek API(把 `spring.ai.openai.base-url` 指向 `https://api.deepseek.com`)。这种方式能跑,但有几个痛点:\n\n- 拿不到 `reasoning_content`:DeepSeek 的推理过程(CoT)不会以结构化字段返回,只能让模型把思考过程包在 `<think>` 标签里,前端再用状态机做标签解析,极其脆弱\n- 缺失 DeepSeek 特有 API:Prefix Completion、DeepSeek 特有的参数等都无法使用\n- 语义不清晰:配置里写着 openai,实际调的却是 DeepSeek,维护成本高\n\nSpring AI 在 2.x 版本已经官方支持了 DeepSeek,本文记录完整的迁移过程。\n\n## 添加依赖\n\n在 `pom.xml` 中添加 DeepSeek Starter(版本由 BOM 2.0.0 统一管理):\n\n```xml\n<dependency>\n <groupId>org.springframework.ai</groupId>\n <artifactId>spring-ai-starter-model-deepseek</artifactId>\n</dependency>\n```\n\n## 配置供应商\n\n在 `application-dev.yml` 中添加 DeepSeek 配置块:\n\n```yaml\nspring:\n ai:\n deepseek:\n api-key: sk-xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx\n chat:\n options:\n model: deepseek-v4-flash\n temperature: 1.0\n```\n\nAPI Key 建议通过环境变量注入,避免硬编码。\n\n## 注册 Bean\n\n在 `AiConfig.java` 中注册 DeepSeek 的 ChatModel 和 ChatClient:\n\n```java\nimport org.springframework.ai.deepseek.DeepSeekChatModel;\n\n@Bean(\"deepseekChatModel\")\npublic ChatModel deepseekChatModel(DeepSeekChatModel deepSeekChatModel) {\n return deepSeekChatModel;\n}\n\n@Bean(\"deepseekChatClient\")\npublic ChatClient deepseekChatClient(\n @Qualifier(\"deepseekChatModel\") ChatModel deepseekChatModel,\n MessageFormatAdvisor messageFormatAdvisor,\n LifecycleToolCallAdvisor lifecycleToolCallAdvisor,\n TaskProgressAdvisor taskProgressAdvisor,\n RetryAdvisor retryAdvisor) {\n return ChatClient.builder(deepseekChatModel)\n .defaultToolContext(new HashMap<>(Map.of(\"debug\", true)))\n .defaultAdvisors(\n messageFormatAdvisor,\n lifecycleToolCallAdvisor,\n taskProgressAdvisor,\n retryAdvisor\n )\n .build();\n}\n```\n\n## Controller 改造 — 原生推理流式输出\n\n_改造前的痛点_:每个 SSE Chunk 拿到的是 AssistantMessage,`<think>` 标签可能被切碎在多个 Chunk 里,需要维护复杂的状态机做拼接。\n\n_改造后_:使用 `DeepSeekAssistantMessage`,`reasoningContent` 和 `text` 是两个独立的字段:\n\n```java\nimport org.springframework.ai.deepseek.DeepSeekAssistantMessage;\n\n// 流式处理核心逻辑\n.concatMap(response -> {\n AssistantMessage output = response.getResult().getOutput();\n List<ServerSentEvent<ChatChunk>> events = new ArrayList<>();\n\n // 工具调用\n if (output.getToolCalls() != null && !output.getToolCalls().isEmpty()) {\n // ... handle tool calls\n return Flux.fromIterable(events);\n }\n\n // DeepSeek 原生推理内容\n if (output instanceof DeepSeekAssistantMessage dsMsg) {\n String reasoning = dsMsg.getReasoningContent();\n if (reasoning != null && !reasoning.isEmpty()) {\n // 缓冲后发送(见下文)\n state.accumulateReasoning(reasoning, events);\n }\n }\n\n // 文本内容\n String text = output.getText();\n if (text != null && !text.isEmpty()) {\n state.flushReasoning(events);\n events.add(createEvent(\"message\", state.messageId(), text, null));\n }\n\n return Flux.fromIterable(events);\n})\n```\n\n## 推理内容缓冲优化\n\n`reasoningContent` 以 Token 粒度到达,每个 SSE Chunk 可能只有一个字/词,直接推给前端会导致渲染碎片化。需要在服务端做缓冲,按语义边界批量下发。\n\n核心实现:累积推理内容到 StringBuilder,遇到句子结束标点时切分发出:\n\n```java\nprivate static class StreamState {\n private static final int REASONING_FLUSH_THRESHOLD = 50;\n private static final Pattern SENTENCE_BOUNDARY =\n Pattern.compile(\"[。!?.!?\\\\n]+\");\n\n private final StringBuilder reasoningBuffer = new StringBuilder();\n\n public void accumulateReasoning(String delta,\n List<ServerSentEvent<ChatChunk>> target) {\n reasoningBuffer.append(delta);\n\n // 按标点切分,整句发出\n String buf = reasoningBuffer.toString();\n var matcher = SENTENCE_BOUNDARY.matcher(buf);\n int lastEnd = 0;\n while (matcher.find()) {\n String segment = buf.substring(lastEnd, matcher.end()).trim();\n if (!segment.isEmpty()) {\n target.add(createEvent(\"thought\", \"reasoning\", segment, null));\n }\n lastEnd = matcher.end();\n }\n reasoningBuffer.delete(0, lastEnd);\n\n // 无标点时强制 flush(避免长思考无反馈)\n if (reasoningBuffer.length() > REASONING_FLUSH_THRESHOLD) {\n String forced = reasoningBuffer.toString();\n reasoningBuffer.setLength(0);\n target.add(createEvent(\"thought\", \"reasoning\", forced, null));\n }\n }\n}\n```\n\n触发策略:\n\n| 场景 | 行为 |\n| :--------------------------- | :------------------- |\n| 遇到句号/问号/感叹号/换行 | 按标点切分,整句发出 |\n| 缓冲区积累超过 50 字符无标点 | 强制整块发出 |\n| 切换到文本输出或工具调用 | 排空缓存 |\n\n## 效果对比\n\n改造前: 前端收到的是逐个单词的 `thought` 事件,需要前端做拼接渲染:\n\n```json\nevent: thought\ndata: {\"content\":\"The\", \"role\":\"thought\"}\nevent: thought\ndata: {\"content\":\"user\", \"role\":\"thought\"}\nevent: thought\ndata: {\"content\":\"wants\", \"role\":\"thought\"}\n```\n\n改造后: 前端收到完整的语义段落,直接展示:\n\n```json\nevent: thought\ndata: {\"content\":\"The user wants me to add a new feature.\", \"role\":\"thought\"}\nevent: thought\ndata: {\"content\":\"Let me think about the best approach.\", \"role\":\"thought\"}\n```\n\n## 总结\n\nSpring AI 官方 DeepSeek Starter 带来的核心收益:\n\n- 结构化推理内容:`DeepSeekAssistantMessage.getReasoningContent()` 直接获取 CoT,无需 `<think>` 标签 hack\n- 服务端缓冲:按语义边界批量下发,前端零改动即可获得平滑渲染\n- 配置语义化:配置即文档,`spring.ai.deepseek.*` 一目了然\n- 扩展性:后续可以无缝使用 DeepSeek 特有功能(Prefix Completion、Reasoning 多轮对话等)\n"}}}} |