Spring Boot 接通义千问:从拿 Key 到第一个对话接口跑通(保姆级)

本文是《Java 后端 + AI 落地(Spring AI 主线)》系列第 2 篇。上一篇讲清了"为什么用框架、六个抽象各解决什么、2026 年这条线上的版本岔路口",但真正卡住大多数团队的不是框架,是第一天的那半小时 :Key 去哪拿、模型名写什么、base-url 是哪个、报 401 到底是哪一层的问题。这篇把两条可行路线(Boot 3.5 + Spring AI 1.1.x + Spring AI Alibaba 原生;Boot 4.1 + Spring AI 2.0.1 + OpenAI 兼容端点)的完整代码、配置、验证脚本和报错对照表 给全,并给出一个我认为比代码更重要的方法论------分层排错。面向已经会写 Spring Boot、但还没接过大模型的工程师。

📌 版本声明 :本文基于 2026-09-20 的版本线撰写。路线 B 用 Spring AI 2.0.1 + Spring Boot 4.1.x + Java 21 ;路线 A 用 Spring AI 1.1.x + Spring Boot 3.5.x + Spring AI Alibaba 1.1.2.2 。Spring Boot 3.5 / Spring Framework 6.2 已于 2026-06-30 进入 EOL ,路线 A 仅为存量项目过渡而保留,新项目请直接走路线 B 。所有依赖坐标、配置键名与模型名请以官方 Reference Documentation 和阿里云百炼控制台为准 ,版本迭代很快,本文可能已滞后。文中价格、免费额度等信息一律以控制台实时公示为准,本文不做承诺。

文章目录

    • [1️⃣ 先把"选供应商"这件事想明白](#1️⃣ 先把"选供应商"这件事想明白)
      • [1.1 上一篇没解决的那个问题](#1.1 上一篇没解决的那个问题)
      • [1.2 先破除一个误解:接国产模型不等于必须用国产 SDK](#1.2 先破除一个误解:接国产模型不等于必须用国产 SDK)
      • [1.3 一个务实的推进顺序](#1.3 一个务实的推进顺序)
    • [2️⃣ 前置准备:十分钟,但决定后面所有事](#2️⃣ 前置准备:十分钟,但决定后面所有事)
      • [2.1 拿 API Key,以及三个容易吃亏的细节](#2.1 拿 API Key,以及三个容易吃亏的细节)
      • [2.2 选模型:别一上来就用最强的](#2.2 选模型:别一上来就用最强的)
      • [2.3 本地环境核对:三条命令](#2.3 本地环境核对:三条命令)
      • [2.4 分层排错:比代码更重要的方法论](#2.4 分层排错:比代码更重要的方法论)
    • [3️⃣ 路线 A:Boot 3.5 + Spring AI 1.1.x + Spring AI Alibaba(原生)](#3️⃣ 路线 A:Boot 3.5 + Spring AI 1.1.x + Spring AI Alibaba(原生))
      • [3.1 依赖](#3.1 依赖)
      • [3.2 配置](#3.2 配置)
      • [3.3 最小可运行代码(1.1.x 的写法)](#3.3 最小可运行代码(1.1.x 的写法))
      • [3.4 跑起来并验证](#3.4 跑起来并验证)
      • [3.5 这条路的真实代价:版本锁链](#3.5 这条路的真实代价:版本锁链)
    • [4️⃣ 路线 B:Boot 4.1 + Spring AI 2.0.1 + 兼容端点(推荐)](#4️⃣ 路线 B:Boot 4.1 + Spring AI 2.0.1 + 兼容端点(推荐))
      • [4.1 一句话理由与它的代价](#4.1 一句话理由与它的代价)
      • [4.2 依赖](#4.2 依赖)
      • [4.3 配置:这里有三个静默失效的坑](#4.3 配置:这里有三个静默失效的坑)
      • [4.4 三个接口:同步、结构化、流式](#4.4 三个接口:同步、结构化、流式)
      • [4.5 兼容模式的真实代价:协议外参数怎么办](#4.5 兼容模式的真实代价:协议外参数怎么办)
      • [4.6 从 1.1.x 迁到 2.0 的最小检查清单](#4.6 从 1.1.x 迁到 2.0 的最小检查清单)
    • [5️⃣ 跑通之后,立刻要做的四件事](#5️⃣ 跑通之后,立刻要做的四件事)
      • [5.1 第一件:把 Key 从配置文件里赶出去](#5.1 第一件:把 Key 从配置文件里赶出去)
      • [5.2 第二件:给第一个接口加"三件套"](#5.2 第二件:给第一个接口加"三件套")
      • [5.3 第三件:可观测------让 token 和延迟自己说话](#5.3 第三件:可观测——让 token 和延迟自己说话)
      • [5.4 第四件:把账单变成可预测的数](#5.4 第四件:把账单变成可预测的数)
    • [6️⃣ 适用边界与风险提示](#6️⃣ 适用边界与风险提示)
      • [6.1 ⚠️ 适用场景](#6.1 ⚠️ 适用场景)
      • [6.2 ⚠️ 不适用场景](#6.2 ⚠️ 不适用场景)
      • [6.3 ⚠️ 版本与兼容性注意](#6.3 ⚠️ 版本与兼容性注意)
      • [6.4 ⚠️ 生产建议](#6.4 ⚠️ 生产建议)
      • [6.5 六个高频问题](#6.5 六个高频问题)
    • [7️⃣ 总结](#7️⃣ 总结)
      • [7.1 回到开头:第一天的半小时到底难在哪](#7.1 回到开头:第一天的半小时到底难在哪)
      • [7.2 本篇的五个核心决策点](#7.2 本篇的五个核心决策点)
      • [7.3 一页纸速查](#7.3 一页纸速查)
      • [7.4 系列地图](#7.4 系列地图)
    • 参考资料

1️⃣ 先把"选供应商"这件事想明白

1.1 上一篇没解决的那个问题

上一篇我给了一个版本对应表,里面藏着一个不太好接受的结论:在 2026 年 9 月这个时点,"用最新版 Spring AI 2.0 + 国产模型的原生 SDK 支持"不是一个能直接满足的组合。 原因不在阿里云,也不在 Spring 官方,而是框架生态的版本节奏天然错位------上游一次大版本重设(2.0 的 Options API 重构、Jackson 3、工具调用挪到 Advisor 层)之后,下游适配库必然要花几个月跟上。

所以摆在 Java 工程师面前的是两条路,而且这两条路不是"对 vs 错",是"现在 vs 以后":

  • 路线 A :留在 Spring Boot 3.5 + Spring AI 1.1.x,用 Spring AI Alibaba 提供的原生 DashScope 适配 。好处是"原生"两个字------平台独有的能力(联网搜索、应用编排、部分多模态特性)能直接用;代价是你的三个版本被锁在一起,而且Boot 3.5 已经 EOL。
  • 路线 B :升级到 Spring Boot 4.1 + Spring AI 2.0.1,用OpenAI 兼容端点 接通义千问。好处是代码与供应商彻底解耦、升级路径干净;代价是"协议之外的能力"用不了,或者要用一点非标准手段绕。

这篇文章两条都给,但我的推荐是明确的:新项目走 B。 理由下面会展开,这里先给一句话版本------兼容模式的上限是"协议",原生 SDK 的上限是"平台能力";而绝大多数业务系统在头半年根本用不到那些平台独有能力,却要为它们付出版本锁死的代价。

1.2 先破除一个误解:接国产模型不等于必须用国产 SDK

这个误解是我在咨询里遇到最多的,它造成的后果也很具体:团队为了"支持国产",把项目绑在了一个版本跟进更慢的适配库上,结果每次想升级 Spring Boot 都得等三方对齐。

事实是:通义千问(通过阿里云百炼)同时提供两套接入方式 ------一套是 DashScope 原生 API,一套是 OpenAI 兼容接口(compatible-mode)。只要走兼容接口,你的 Java 代码里就一行"阿里"字样都不需要出现 :base-url 指向它、api-key 换成它的、model 写通义的模型名,代码结构与接 GPT 完全一致。

这意味着"国产合规"和"代码解耦"是可以同时拿到的,代价只是放弃协议之外的私有参数。我把两条路线的差异摊开成一张表:

维度 OpenAI 兼容模式(路线 B) 原生 DashScope SDK(路线 A)
代码耦合度 零耦合,换供应商只改 base-url + model 与 com.alibaba.cloud.ai 包强耦合,换供应商要改代码
可用参数 OpenAI 协议内的:temperature / top_p / max_tokens / tools / response_format / seed / stop 协议内 + 平台私有:联网搜索、top_k、repetition_penalty、部分多模态参数
平台独有能力 不可用(或需非标准手段,见 4.5) 可直接用:应用编排、Agent 类、平台侧知识库绑定
版本锁链 只依赖 Spring AI 一条线 Spring Boot ↔ Spring Framework ↔ Spring AI ↔ Spring AI Alibaba 四条线必须同时对齐
升级节奏 跟 Spring 官方节奏 跟三方适配节奏,2026-09 时仍未跟进 2.0
调试体验 标准 HTTP 语义,任何 OpenAI 生态的工具(curl、Postman、SDK)都能复现 需要看两套文档,报错信息也更"平台化"
适合阶段 新项目起步、PoC、以及"不需要平台独有特性"的所有业务系统 明确需要平台独有能力,或存量项目已在用

这张表最该看的是倒数第二行的"版本锁链",因为它是唯一会长期扣你成本的一行。 我用一个真实场景说明:某项目在 2026 年初用 Spring Boot 3.4 + Spring AI 1.0.0 + Alibaba 1.0.0.2 起步,6 月出了个 Spring Framework 的安全公告,运维要求两周内升级;但升级到 Boot 3.5 需要 Alibaba 那个适配库同步发版,而它的发版节奏是"跟 Spring AI 的 GA 走" ------最后这个安全升级拖了将近一个月,期间系统带着已知漏洞在生产上跑。这不是谁的错,这就是多一条依赖链的必然结果。

1.3 一个务实的推进顺序

我给的建议是三步,不要跳步:

第一步,用兼容模式做 PoC。 目标只有两个:业务上验证提示词与输出质量,工程上验证网络连通与延迟量级。这一步不要纠结模型选哪家、参数怎么调,那都是后面的事。

第二步,PoC 过了之后,再评估"是否真的需要原生能力"。 判断标准很硬:如果你的需求清单里出现了"联网搜索""平台侧知识库""应用编排"这类词,才考虑切原生 SDK;否则保持兼容模式。 我见过不少团队在这一步切了原生 SDK,最后实际用到的私有参数是零。

第三步,如果确实要用原生能力,也不要全身切。 保留兼容模式作为主干,只在需要那个能力的少数接口上用原生 SDK,用两个显式命名的 ChatClient Bean 隔离开 (具体写法见 6.5 的 Q3)。这么做的好处是"平台迁移成本被限制在一个接口里",而不是扩散到整个工程。 代价是你多背一套依赖、多一套配置------所以只有当你真的确认用得上,这笔账才算得平。

🖼️ 配图说明(映射矩阵信息图 | 建议文件名:b2_adapter.png)

画面内容:采用参考图 4 的上下两层矩阵版式,表达"上层不动、下层可换"。上层容器放 4 张卡,代表业务侧四处不需要改的地方:①「Service 调用」「ChatClient.prompt() 写法不变」②「结构化输出」「.entity(Record.class) 不变」③「流式输出」「Flux 逐块返回不变」④「多轮对话」「Message 列表不变」。上层 4 卡下缘各引一条竖向虚线,汇入画面正中的橙色渐变双箭头,箭头中央标签写「换供应商只改配置」。下层容器放 2 张宽卡:左「OpenAI 兼容端点」「base-url 一改即换,模型名在不同模式下不完全一致」;右「Ollama 私有端口」「内网自建端点,接口形状与兼容端点一致」。两张下层卡右上角各有一枚可替换的双向箭头图标,暗示两块是同构可互换的。画面左侧边缘再补一条竖直的细信息带,从上到下写「依赖坐标 → 配置文件 → 端点」,说明"要改的东西只有这三处"。上下两层与左侧信息带共同铺满画幅。

画面文字清单(生成后可直接排版覆盖,若生图中文有笔画错误就照此重打):Service 调用、ChatClient.prompt() 写法不变、结构化输出、.entity(Record.class) 不变、流式输出、Flux 逐块返回不变、多轮对话、Message 列表不变、换供应商只改配置、OpenAI 兼容端点、base-url 一改即换,模型名在不同模式下不完全一致、Ollama 私有端口、内网自建端点,接口形状与兼容端点一致、依赖坐标 → 配置文件 → 端点

GPT-image-2 提示词(英文,可直接复制):

text 复制代码
Layer-mapping information graphic for a Java AI framework, upper layer container of four white cards each holding a gradient circular icon and a code line caption, thin dotted vertical lines from all four cards converging into an orange gradient double arrow in the middle carrying a Chinese label, lower layer container of two wider cards each with a swap-arrows icon, a narrow vertical strip along the left edge listing three short steps, Chinese labels printed directly on every card and strip, clean light blue-white matrix infographic, two stacked white rounded layer containers with soft shadows, four cards on the upper layer and two wider cards on the lower layer, each card holding a gradient circular icon, a heading and a small object image, an orange gradient double arrow between the layers with a centered label, thin dotted vertical connector lines, deep navy bold headings with pale blue sub-labels, flat vector, all Chinese labels rendered sharply in a clean bold sans-serif typeface with uniform label size and correct strokes, no watermark, no signature

2️⃣ 前置准备:十分钟,但决定后面所有事

2.1 拿 API Key,以及三个容易吃亏的细节

流程本身很简单:登录阿里云 → 进入百炼控制台 → 开通模型服务 → 在 API-KEY 管理页创建一个 Key。但下面三个细节,每个我都见过有人吃亏。

细节一:Key 只显示一次。 创建后控制台只给你看那一次,关掉页面就再也看不到明文了(后续只能看到尾号)。正确做法是创建完立刻存进密码管理器,并且顺手把它写进你团队的密钥台账------我见过最狼狈的一次是,一个实习生建完 Key 直接关了页面,第二天要部署时发现整个团队没人有明文,只能删了重建、重新走一遍权限审批。

细节二:Key 是绑定到"账号 + 工作空间"的,不是纯账号级的。 一个阿里云账号下可以建多个业务空间,Key 归属于某一个空间,跨空间调用会失败 (表现是 401 或权限类错误,而错误信息通常不会直白地告诉你"空间不对")。**所以团队协作的第一件事是先统一"我们用哪个空间",**并把它写进 README。这一点在多团队共用同一账号的企业里最容易出问题------A 团队建的 Key 在 B 团队的工程里怎么都不通,两边各自排查半天,最后发现是两个空间。

细节三:生产环境不要用主账号 Key。 主账号 Key 的权限是账号级的,泄露等于整个阿里云账号失守 (不只模型服务,还有 OSS、ECS 等)。正确姿势是用 RAM 子账号,只授予模型调用相关的最小权限策略 ,按环境(dev / staging / prod)分别建 Key。这条不是"最佳实践建议",而是审计硬要求------等保测评和内部安全审计都会查这一项,事后再改代码比一开始就规范要多花好几倍力气。

2.2 选模型:别一上来就用最强的

新手最容易犯的错是"直接用最贵的模型"。这在 PoC 阶段是合理的(先用最强的确认能力上限),但一旦进入开发就必须换掉,否则你的成本曲线会在用户量上来后突然失控。

模型族 定位 适合的任务 什么时候不该用
qwen-turbo 一类 快、便宜 分类、改写、短摘要、格式转换、意图识别 复杂推理、长文分析、多步工具调用
qwen-plus 一类 均衡(建议默认) 通用问答、RAG 回答生成、工单处理、客服 极高难度的推理链、超长上下文
qwen-max 一类 最强 复杂推理、长文档理解、代码生成 高频简单任务(成本效益比差)

我的默认推荐是 qwen-plus 这一档,理由不是"它最好",而是"它最不容易错"。 在 PoC 阶段用它能同时看清能力和成本量级;等到你把请求按任务类型分流(简单任务给 turbo、复杂任务给 max),才算真正进入"多模型调度"阶段------那是系列一第 4 篇的主题,在 Spring 技术栈里的落地我们后面会有一篇专门的对比。

关于模型名,有一条必须提前知道的坑:模型名是可以带"快照版本号"的。 比如同一个型号可能同时存在滚动更新的别名和冻结的快照名(形如 xxx-2026-07)。生产环境用别名意味着"你会在某个你不知道的凌晨被静默升级",用快照名意味着"你能复现三个月前的行为,但要自己盯下线公告"。 从工程角度我建议:PoC 用别名(省事),生产用快照名(可控),并且把模型名做成配置项------因为**"换模型"是 AI 应用里最高频的变更**(成本、能力、合规任一变化都会触发它),硬编码在代码里的团队,每次换模型都要走一次发版。

三个必须显式设置的参数:

  • temperature :业务问答、分类、结构化输出这类任务给 0.1~0.3 ;创意类才用 0.7 以上。默认值通常偏高,不改的结果是同一个问题问两遍得到两个不同答案,做回归测试时会让你怀疑人生。
  • max_tokens :不设上限 = 成本不可控 ;设太小 = 回答被半路截断(响应里 finish_reason 会是 length 而不是 stop,这个字段是你排查"回答为什么没说完"的第一现场)。
  • seed (如果供应商支持):用于让同一输入的输出尽量稳定,做测试断言时有用。注意它是"尽力而为"语义,不是严格保证。

关于免费额度 ,我只说方法不说数字:新账号通常有一定额度的免费试用,具体数量、有效期与适用范围以控制台公告为准。 我建议你在 PoC 第一天就做一件事------去控制台把"消费限额"或"额度告警"配好 。原因很实在:AI 接口的成本故障是"静默的",一个死循环重试能在一晚上烧掉一个月的预算,而它在你自己的日志里可能就是几行不起眼的 ERROR。

2.3 本地环境核对:三条命令

检查项 命令 通过标准
JDK java -version 路线 B 要 21;路线 A 要 17 或 21
Maven mvn -v 3.9+,且 JAVA_HOME 指向同一个 JDK
网络连通 见下方 curl 返回 200 且 body 里有 choices 与 usage
bash 复制代码
# 一条 curl 验证三件事:网络通、Key 有效、模型名正确
# 注意 base-url 结尾:兼容模式的路径是 /compatible-mode/v1
# 少了 /v1 或多了斜杠,都会得到 404 而不是一个清晰的错误
export DASHSCOPE_API_KEY="sk-你的Key"

curl -sS -X POST "https://dashscope.aliyuncs.com/compatible-mode/v1/chat/completions" \
  -H "Authorization: Bearer $DASHSCOPE_API_KEY" \
  -H "Content-Type: application/json" \
  -d '{
    "model": "qwen-plus",
    "messages": [
      {"role": "system", "content": "你是企业技术支持助手,回答简洁。"},
      {"role": "user", "content": "用一句话说明什么是向量检索。"}
    ],
    "temperature": 0.2,
    "max_tokens": 256
  }' | head -c 1200

返回体里最该盯住的不是 content,是 usage。 它长这样(字段名以实际返回为准):

json 复制代码
{
  "choices": [{ "index": 0, "finish_reason": "stop",
    "message": { "role": "assistant", "content": "......" } }],
  "usage": { "prompt_tokens": 38, "completion_tokens": 52, "total_tokens": 90 }
}

把这次的 usage 记在笔记里。 它是你后面所有成本估算的基线:你的提示词有多长、一次回答大概花多少 token、按当前单价乘出来一个月大概多少钱 ,全靠这个数。没有这个基线的团队,第一次看到账单时通常都是"远超预期"的反应 ------因为提示词里的系统提示、检索到的文档片段、对话历史,全都会计入 prompt_tokens,而这三块往往是用户输入的好几倍。

2.4 分层排错:比代码更重要的方法论

这一节是我认为本文最有价值的部分,因为它能省下你后面几个月里大量"在同一层里反复试"的时间。

大模型调用出错时,工程师最常见的反应是"改代码再试一次"。但这条链路上有四层,报错只告诉你"结果不对",不告诉你"哪层不对"。 盲目改代码的问题在于:如果问题在第一层(凭据/网络),你改一天 Java 也没用。

我把四层分清楚:

  • 第一层:凭据与网络。 Key 是否有效、是否有权限、网络是否可达、base-url 路径是否正确。判定手段只有一个:curl。 curl 通了,这一层就结束了,后面无论出什么错,都不再怀疑 Key。
  • 第二层:框架配置。 application.yml 的键名、缩进、环境变量是否真的注入进来了。这一层的典型特征是"配置写了但没生效" ------比如 2.0 把 1.x 的 .options 中间段去掉了,你照旧写法粘过来,Spring 不会报错,只会静默用默认值,表现是"模型名是我改过的,但实际调用的还是默认那个"。
  • 第三层:代码与抽象。 ChatClient 的用法、Advisor 链顺序、Prompt 组装。这一层的报错通常有明确堆栈,反而好查。
  • 第四层:输入与提示词。 接口 200、内容却不对。这一层不是"故障",是需要调优------把它和前三层混在一起排,是你以为在修 bug、实际在做提示词工程的根源。

#mermaid-svg-uV11NN7qNFBmuVP1{font-family:"trebuchet ms",verdana,arial,sans-serif;font-size:16px;fill:#333;}@keyframes edge-animation-frame{from{stroke-dashoffset:0;}}@keyframes dash{to{stroke-dashoffset:0;}}#mermaid-svg-uV11NN7qNFBmuVP1 .edge-animation-slow{stroke-dasharray:9,5!important;stroke-dashoffset:900;animation:dash 50s linear infinite;stroke-linecap:round;}#mermaid-svg-uV11NN7qNFBmuVP1 .edge-animation-fast{stroke-dasharray:9,5!important;stroke-dashoffset:900;animation:dash 20s linear infinite;stroke-linecap:round;}#mermaid-svg-uV11NN7qNFBmuVP1 .error-icon{fill:#552222;}#mermaid-svg-uV11NN7qNFBmuVP1 .error-text{fill:#552222;stroke:#552222;}#mermaid-svg-uV11NN7qNFBmuVP1 .edge-thickness-normal{stroke-width:1px;}#mermaid-svg-uV11NN7qNFBmuVP1 .edge-thickness-thick{stroke-width:3.5px;}#mermaid-svg-uV11NN7qNFBmuVP1 .edge-pattern-solid{stroke-dasharray:0;}#mermaid-svg-uV11NN7qNFBmuVP1 .edge-thickness-invisible{stroke-width:0;fill:none;}#mermaid-svg-uV11NN7qNFBmuVP1 .edge-pattern-dashed{stroke-dasharray:3;}#mermaid-svg-uV11NN7qNFBmuVP1 .edge-pattern-dotted{stroke-dasharray:2;}#mermaid-svg-uV11NN7qNFBmuVP1 .marker{fill:#333333;stroke:#333333;}#mermaid-svg-uV11NN7qNFBmuVP1 .marker.cross{stroke:#333333;}#mermaid-svg-uV11NN7qNFBmuVP1 svg{font-family:"trebuchet ms",verdana,arial,sans-serif;font-size:16px;}#mermaid-svg-uV11NN7qNFBmuVP1 p{margin:0;}#mermaid-svg-uV11NN7qNFBmuVP1 .label{font-family:"trebuchet ms",verdana,arial,sans-serif;color:#333;}#mermaid-svg-uV11NN7qNFBmuVP1 .cluster-label text{fill:#333;}#mermaid-svg-uV11NN7qNFBmuVP1 .cluster-label span{color:#333;}#mermaid-svg-uV11NN7qNFBmuVP1 .cluster-label span p{background-color:transparent;}#mermaid-svg-uV11NN7qNFBmuVP1 .label text,#mermaid-svg-uV11NN7qNFBmuVP1 span{fill:#333;color:#333;}#mermaid-svg-uV11NN7qNFBmuVP1 .node rect,#mermaid-svg-uV11NN7qNFBmuVP1 .node circle,#mermaid-svg-uV11NN7qNFBmuVP1 .node ellipse,#mermaid-svg-uV11NN7qNFBmuVP1 .node polygon,#mermaid-svg-uV11NN7qNFBmuVP1 .node path{fill:#ECECFF;stroke:#9370DB;stroke-width:1px;}#mermaid-svg-uV11NN7qNFBmuVP1 .rough-node .label text,#mermaid-svg-uV11NN7qNFBmuVP1 .node .label text,#mermaid-svg-uV11NN7qNFBmuVP1 .image-shape .label,#mermaid-svg-uV11NN7qNFBmuVP1 .icon-shape .label{text-anchor:middle;}#mermaid-svg-uV11NN7qNFBmuVP1 .node .katex path{fill:#000;stroke:#000;stroke-width:1px;}#mermaid-svg-uV11NN7qNFBmuVP1 .rough-node .label,#mermaid-svg-uV11NN7qNFBmuVP1 .node .label,#mermaid-svg-uV11NN7qNFBmuVP1 .image-shape .label,#mermaid-svg-uV11NN7qNFBmuVP1 .icon-shape .label{text-align:center;}#mermaid-svg-uV11NN7qNFBmuVP1 .node.clickable{cursor:pointer;}#mermaid-svg-uV11NN7qNFBmuVP1 .root .anchor path{fill:#333333!important;stroke-width:0;stroke:#333333;}#mermaid-svg-uV11NN7qNFBmuVP1 .arrowheadPath{fill:#333333;}#mermaid-svg-uV11NN7qNFBmuVP1 .edgePath .path{stroke:#333333;stroke-width:2.0px;}#mermaid-svg-uV11NN7qNFBmuVP1 .flowchart-link{stroke:#333333;fill:none;}#mermaid-svg-uV11NN7qNFBmuVP1 .edgeLabel{background-color:rgba(232,232,232, 0.8);text-align:center;}#mermaid-svg-uV11NN7qNFBmuVP1 .edgeLabel p{background-color:rgba(232,232,232, 0.8);}#mermaid-svg-uV11NN7qNFBmuVP1 .edgeLabel rect{opacity:0.5;background-color:rgba(232,232,232, 0.8);fill:rgba(232,232,232, 0.8);}#mermaid-svg-uV11NN7qNFBmuVP1 .labelBkg{background-color:rgba(232, 232, 232, 0.5);}#mermaid-svg-uV11NN7qNFBmuVP1 .cluster rect{fill:#ffffde;stroke:#aaaa33;stroke-width:1px;}#mermaid-svg-uV11NN7qNFBmuVP1 .cluster text{fill:#333;}#mermaid-svg-uV11NN7qNFBmuVP1 .cluster span{color:#333;}#mermaid-svg-uV11NN7qNFBmuVP1 div.mermaidTooltip{position:absolute;text-align:center;max-width:200px;padding:2px;font-family:"trebuchet ms",verdana,arial,sans-serif;font-size:12px;background:hsl(80, 100%, 96.2745098039%);border:1px solid #aaaa33;border-radius:2px;pointer-events:none;z-index:100;}#mermaid-svg-uV11NN7qNFBmuVP1 .flowchartTitleText{text-anchor:middle;font-size:18px;fill:#333;}#mermaid-svg-uV11NN7qNFBmuVP1 rect.text{fill:none;stroke-width:0;}#mermaid-svg-uV11NN7qNFBmuVP1 .icon-shape,#mermaid-svg-uV11NN7qNFBmuVP1 .image-shape{background-color:rgba(232,232,232, 0.8);text-align:center;}#mermaid-svg-uV11NN7qNFBmuVP1 .icon-shape p,#mermaid-svg-uV11NN7qNFBmuVP1 .image-shape p{background-color:rgba(232,232,232, 0.8);padding:2px;}#mermaid-svg-uV11NN7qNFBmuVP1 .icon-shape .label rect,#mermaid-svg-uV11NN7qNFBmuVP1 .image-shape .label rect{opacity:0.5;background-color:rgba(232,232,232, 0.8);fill:rgba(232,232,232, 0.8);}#mermaid-svg-uV11NN7qNFBmuVP1 .label-icon{display:inline-block;height:1em;overflow:visible;vertical-align:-0.125em;}#mermaid-svg-uV11NN7qNFBmuVP1 .node .label-icon path{fill:currentColor;stroke:revert;stroke-width:revert;}#mermaid-svg-uV11NN7qNFBmuVP1 :root{--mermaid-font-family:"trebuchet ms",verdana,arial,sans-serif;} 失败
成功
与预期不符
一致
有异常
无异常
接口报错 / 结果异常
第一层:curl

直接打供应商端点
停在第一层排查

Key / 权限 / 空间 / base-url 路径 / 网络出口
第二层:配置是否真的生效

打印已解析的配置项
键名错 / 缩进错 / 环境变量没注入

旧版本写法被静默忽略
第三层:代码与 Advisor 链

看堆栈
ChatClient 用法 / Prompt 组装 / Bean 冲突
第四层:输出质量

不是故障,是调优
提示词 / 上下文 / 温度 / 模型档位
修完回到起点复测

(图 1:四层排错决策树------任何报错,先问"它在哪一层",不要跨层试)

上面这张图里我要特别强调一条纪律:不要跳层。 我见过一个很典型的排查过程------接口报 401,工程师怀疑是 Spring AI 的 Key 配置问题,改了 yml、加了环境变量、重启了三次应用,都没用;最后用 curl 一试,发现 Key 本身就已经过期了,前两个小时全部花在第二层,而问题在第一层。 反过来也有:curl 明明能通、Java 里就是不行,这时候还在反复 curl 就是浪费时间,问题一定在配置注入或代码层。

顺着这个思路,我整理了一张报错对照表。注意"真因分布"这一列------它告诉你同一个错误码常见的几种原因,按出现概率排序,你照着从第一个开始排。

现象 大概率在哪层 真因分布(按概率) 第一动作
401 Unauthorized 第一层 ① Key 写错/有空格/已删除 ② Key 属于另一个工作空间 ③ Authorization 头格式不对(缺 Bearer )④ 环境变量没注入成功 先 curl,再打印实际生效的 Key 尾号
403 Forbidden 第一层 ① 该模型未开通 ② 子账号缺少模型调用权限策略 ③ 账号欠费/被限制 去控制台看模型开通状态与账号状态
404 Not Found 第一层 ① base-url 少了 /v1 或路径拼错 ② 网关重写了路径 把 base-url 打印出来逐字符比对
400 model not found 第一层 ① 模型名拼错/大小写 ② 兼容模式与原生模式的模型名不完全一致 ③ 用了该账号未开通的档位 用 curl 换几个候选名快速二分
400 invalid parameter 第一/二层 ① 传了协议不支持的参数(兼容模式的常见坑 )② max_tokens 类型或范围不对 把请求体精简到最小可复现集合
429 Too Many Requests 第一层 ① 触发了 QPS/并发限流 ② 账户级额度耗尽(与限流表现相似) 看响应体与响应头,区分限流与欠费
504 / 读取超时 第二层 ① 客户端 read-timeout 太短 ② 长提示词导致首 token 延迟变大 ③ 中间网关提前掐断 先测首 token 延迟,再调超时
200 但内容为空 第三/四层 ① 模型只返回了工具调用请求(没有最终文本 )② 输出被 max_tokens 截断 ③ 安全策略拦截 打印完整响应体,看 finish_reason
响应慢但正确 第四层 ① 提示词太长 ② 选了高档位模型 ③ 流式没开,用户要等全部生成完 分开量化"首 token 延迟"与"总耗时"

这张表里我特别想让你记住第 8 行和第 9 行。 "200 但内容为空"在第 5 篇(函数调用)会反复出现------当模型决定调用工具时,它返回的是工具调用请求而不是文本,你的代码如果只看 content 就会拿到空字符串 ,看起来像"模型没说话",实际它说了,只是说的不是文本。而"响应慢但正确"根本不该当故障来处理------它的正解是开流式让用户先看到字,那是第 8 篇的主题。

到这里,前置准备就结束了。 下面两条路线,你可以按自己的项目现状只读其中一条------但如果你正在做技术选型,建议两条都扫一遍,因为"知道另一条路的代价"才是决策的依据。


3️⃣ 路线 A:Boot 3.5 + Spring AI 1.1.x + Spring AI Alibaba(原生)

3.1 依赖

xml 复制代码
<!-- 父工程仍用 Boot 3.5.x。注意这条线的 EOL 状态,见 3.5 -->
<parent>
    <groupId>org.springframework.boot</groupId>
    <artifactId>spring-boot-starter-parent</artifactId>
    <version>3.5.x</version>
</parent>

<properties>
    <java.version>21</java.version>
    <!-- 两个 BOM 都要,缺一不可:Alibaba 的 starter 依赖 Spring AI 的模块 -->
    <spring-ai.version>1.1.x</spring-ai.version>
    <spring-ai-alibaba.version>1.1.2.2</spring-ai-alibaba.version>
</properties>

<dependencyManagement>
    <dependencies>
        <dependency>
            <groupId>org.springframework.ai</groupId>
            <artifactId>spring-ai-bom</artifactId>
            <version>${spring-ai.version}</version>
            <type>pom</type>
            <scope>import</scope>
        </dependency>
        <dependency>
            <groupId>com.alibaba.cloud.ai</groupId>
            <artifactId>spring-ai-alibaba-bom</artifactId>
            <version>${spring-ai-alibaba.version}</version>
            <type>pom</type>
            <scope>import</scope>
        </dependency>
    </dependencies>
</dependencyManagement>

<dependencies>
    <dependency>
        <groupId>org.springframework.boot</groupId>
        <artifactId>spring-boot-starter-web</artifactId>
    </dependency>

    <!-- 原生 DashScope 适配:一个 starter 带来模型 + 平台能力 -->
    <dependency>
        <groupId>com.alibaba.cloud.ai</groupId>
        <artifactId>spring-ai-alibaba-starter-dashscope</artifactId>
    </dependency>
</dependencies>

这里必须解释三个"为什么",因为它们是这条路线最容易翻车的地方。

第一,为什么两个 BOM 都要? 因为 Alibaba 的 starter 内部依赖了 Spring AI 的若干模块。如果你只 import 了 Alibaba 的 BOM,Spring AI 各模块的版本会由 Alibaba 的 BOM 传递决定;如果你两个都 import,就以先声明者为准。 听起来无所谓,但当两者对同一个模块的期望版本不一致时,你就会遇到"编译通过、运行报 NoSuchMethodError"这类问题 ------而 mvn dependency:tree 里会清楚地显示同一个 org.springframework.ai 下挂着两个版本。所以两个 BOM 都写上、并且让 Spring AI 的 BOM 排在前面,是最不容易出错的做法。

第二,为什么不能手动引 spring-ai-core? 上一篇讲过:2.0 把 spring-ai-core 拆成了 spring-ai-commons / spring-ai-model / spring-ai-client-chat 等多个模块;在 1.1.x 这条线上虽然还有 core,但手动加坐标同样会绕开 BOM 仲裁。 规矩很简单:只引 starter,不引中间模块。

第三,为什么版本号要写成属性而不是硬编码? 因为这条路线上的三方版本是联动的,你得能一次性替换。下一节会看到那张对应表。

3.2 配置

yaml 复制代码
# application.yml(路线 A)
spring:
  ai:
    dashscope:
      api-key: ${DASHSCOPE_API_KEY}
      chat:
        options:
          model: qwen-plus
          temperature: 0.2

注意这里和路线 B 的一个结构性差异:spring.ai.dashscope 是 独立的一节**,不是挂在 openai 下的。** 这看起来只是键名不同,但它意味着两套配置在同一个应用里可以并存而不冲突------这一点在 Q3 里有用。

3.3 最小可运行代码(1.1.x 的写法)

java 复制代码
@RestController
@RequestMapping("/api/ai")
public class ChatController {

    private final ChatClient chatClient;

    // 1.1.x 这条线上,DashScope 的 ChatModel 由 starter 自动装配,
    // 直接注入后交给 ChatClient.create 构建即可。
    // 注意:这里没有用 ChatClient.Builder 的自动注入------
    // 在 1.1.x + Alibaba starter 的组合里,Builder 的装配行为与 2.0 不同,
    // 显式 create 能让依赖关系一眼看清,出问题也更容易定位。
    public ChatController(DashScopeChatModel chatModel) {
        this.chatClient = ChatClient.create(chatModel);
    }

    @GetMapping("/chat")
    public String chat(@RequestParam String q) {
        return chatClient.prompt()
                .system("你是企业技术支持助手。回答要简洁,不确定时明确说不知道。")
                .user(q)
                .call()
                .content();
    }
}

为什么我在这里把系统提示词写在方法里,而不是像上一篇那样写在 Builder 的 defaultSystem? 这是一个刻意的示范:1.1.x 的 ChatClient.create(model) 不提供 defaultSystem 那种链式默认值设置,只有 Builder 版本才有。 这不是"哪个更好"的问题,而是"你在哪条版本线上就必须按哪条线的 API 写" ------这正是路线 A 的真实成本之一:你要同时记住两套 API 差异,而网上搜到的示例有一半是另一个版本的写法。

3.4 跑起来并验证

bash 复制代码
# 启动
export DASHSCOPE_API_KEY="sk-你的Key"
mvn spring-boot:run

# 验证:期望返回一句中文,且响应时间在可接受范围
curl -sS "http://localhost:8080/api/ai/chat?q=用一句话说明什么是向量检索"

验证环节我要求你顺手记录两个数:首次请求耗时和第二次请求耗时。 因为首次往往明显更慢 (JVM 类加载、HTTP 连接建立、模型侧冷启动都可能算进去),只测一次就得出"这个接口 3 秒"的结论,会把一个本来可以优化的延迟问题记成"基线"。 我的经验是测 5 次取后 3 次的平均,作为你后续优化的对照值。

3.5 这条路的真实代价:版本锁链

#mermaid-svg-MfRhmPkBJuc00fPC{font-family:"trebuchet ms",verdana,arial,sans-serif;font-size:16px;fill:#333;}@keyframes edge-animation-frame{from{stroke-dashoffset:0;}}@keyframes dash{to{stroke-dashoffset:0;}}#mermaid-svg-MfRhmPkBJuc00fPC .edge-animation-slow{stroke-dasharray:9,5!important;stroke-dashoffset:900;animation:dash 50s linear infinite;stroke-linecap:round;}#mermaid-svg-MfRhmPkBJuc00fPC .edge-animation-fast{stroke-dasharray:9,5!important;stroke-dashoffset:900;animation:dash 20s linear infinite;stroke-linecap:round;}#mermaid-svg-MfRhmPkBJuc00fPC .error-icon{fill:#552222;}#mermaid-svg-MfRhmPkBJuc00fPC .error-text{fill:#552222;stroke:#552222;}#mermaid-svg-MfRhmPkBJuc00fPC .edge-thickness-normal{stroke-width:1px;}#mermaid-svg-MfRhmPkBJuc00fPC .edge-thickness-thick{stroke-width:3.5px;}#mermaid-svg-MfRhmPkBJuc00fPC .edge-pattern-solid{stroke-dasharray:0;}#mermaid-svg-MfRhmPkBJuc00fPC .edge-thickness-invisible{stroke-width:0;fill:none;}#mermaid-svg-MfRhmPkBJuc00fPC .edge-pattern-dashed{stroke-dasharray:3;}#mermaid-svg-MfRhmPkBJuc00fPC .edge-pattern-dotted{stroke-dasharray:2;}#mermaid-svg-MfRhmPkBJuc00fPC .marker{fill:#333333;stroke:#333333;}#mermaid-svg-MfRhmPkBJuc00fPC .marker.cross{stroke:#333333;}#mermaid-svg-MfRhmPkBJuc00fPC svg{font-family:"trebuchet ms",verdana,arial,sans-serif;font-size:16px;}#mermaid-svg-MfRhmPkBJuc00fPC p{margin:0;}#mermaid-svg-MfRhmPkBJuc00fPC .label{font-family:"trebuchet ms",verdana,arial,sans-serif;color:#333;}#mermaid-svg-MfRhmPkBJuc00fPC .cluster-label text{fill:#333;}#mermaid-svg-MfRhmPkBJuc00fPC .cluster-label span{color:#333;}#mermaid-svg-MfRhmPkBJuc00fPC .cluster-label span p{background-color:transparent;}#mermaid-svg-MfRhmPkBJuc00fPC .label text,#mermaid-svg-MfRhmPkBJuc00fPC span{fill:#333;color:#333;}#mermaid-svg-MfRhmPkBJuc00fPC .node rect,#mermaid-svg-MfRhmPkBJuc00fPC .node circle,#mermaid-svg-MfRhmPkBJuc00fPC .node ellipse,#mermaid-svg-MfRhmPkBJuc00fPC .node polygon,#mermaid-svg-MfRhmPkBJuc00fPC .node path{fill:#ECECFF;stroke:#9370DB;stroke-width:1px;}#mermaid-svg-MfRhmPkBJuc00fPC .rough-node .label text,#mermaid-svg-MfRhmPkBJuc00fPC .node .label text,#mermaid-svg-MfRhmPkBJuc00fPC .image-shape .label,#mermaid-svg-MfRhmPkBJuc00fPC .icon-shape .label{text-anchor:middle;}#mermaid-svg-MfRhmPkBJuc00fPC .node .katex path{fill:#000;stroke:#000;stroke-width:1px;}#mermaid-svg-MfRhmPkBJuc00fPC .rough-node .label,#mermaid-svg-MfRhmPkBJuc00fPC .node .label,#mermaid-svg-MfRhmPkBJuc00fPC .image-shape .label,#mermaid-svg-MfRhmPkBJuc00fPC .icon-shape .label{text-align:center;}#mermaid-svg-MfRhmPkBJuc00fPC .node.clickable{cursor:pointer;}#mermaid-svg-MfRhmPkBJuc00fPC .root .anchor path{fill:#333333!important;stroke-width:0;stroke:#333333;}#mermaid-svg-MfRhmPkBJuc00fPC .arrowheadPath{fill:#333333;}#mermaid-svg-MfRhmPkBJuc00fPC .edgePath .path{stroke:#333333;stroke-width:2.0px;}#mermaid-svg-MfRhmPkBJuc00fPC .flowchart-link{stroke:#333333;fill:none;}#mermaid-svg-MfRhmPkBJuc00fPC .edgeLabel{background-color:rgba(232,232,232, 0.8);text-align:center;}#mermaid-svg-MfRhmPkBJuc00fPC .edgeLabel p{background-color:rgba(232,232,232, 0.8);}#mermaid-svg-MfRhmPkBJuc00fPC .edgeLabel rect{opacity:0.5;background-color:rgba(232,232,232, 0.8);fill:rgba(232,232,232, 0.8);}#mermaid-svg-MfRhmPkBJuc00fPC .labelBkg{background-color:rgba(232, 232, 232, 0.5);}#mermaid-svg-MfRhmPkBJuc00fPC .cluster rect{fill:#ffffde;stroke:#aaaa33;stroke-width:1px;}#mermaid-svg-MfRhmPkBJuc00fPC .cluster text{fill:#333;}#mermaid-svg-MfRhmPkBJuc00fPC .cluster span{color:#333;}#mermaid-svg-MfRhmPkBJuc00fPC div.mermaidTooltip{position:absolute;text-align:center;max-width:200px;padding:2px;font-family:"trebuchet ms",verdana,arial,sans-serif;font-size:12px;background:hsl(80, 100%, 96.2745098039%);border:1px solid #aaaa33;border-radius:2px;pointer-events:none;z-index:100;}#mermaid-svg-MfRhmPkBJuc00fPC .flowchartTitleText{text-anchor:middle;font-size:18px;fill:#333;}#mermaid-svg-MfRhmPkBJuc00fPC rect.text{fill:none;stroke-width:0;}#mermaid-svg-MfRhmPkBJuc00fPC .icon-shape,#mermaid-svg-MfRhmPkBJuc00fPC .image-shape{background-color:rgba(232,232,232, 0.8);text-align:center;}#mermaid-svg-MfRhmPkBJuc00fPC .icon-shape p,#mermaid-svg-MfRhmPkBJuc00fPC .image-shape p{background-color:rgba(232,232,232, 0.8);padding:2px;}#mermaid-svg-MfRhmPkBJuc00fPC .icon-shape .label rect,#mermaid-svg-MfRhmPkBJuc00fPC .image-shape .label rect{opacity:0.5;background-color:rgba(232,232,232, 0.8);fill:rgba(232,232,232, 0.8);}#mermaid-svg-MfRhmPkBJuc00fPC .label-icon{display:inline-block;height:1em;overflow:visible;vertical-align:-0.125em;}#mermaid-svg-MfRhmPkBJuc00fPC .node .label-icon path{fill:currentColor;stroke:revert;stroke-width:revert;}#mermaid-svg-MfRhmPkBJuc00fPC :root{--mermaid-font-family:"trebuchet ms",verdana,arial,sans-serif;} 尚未跟进
无法直接使用
必须等待三方对齐
Spring Boot 3.5.x

2026-06-30 EOL
Spring Framework 6.2

EOL
Spring AI 1.1.x

该分支仅维护
Spring AI Alibaba 1.1.2.2
DashScope 原生能力

联网搜索 / 应用编排 / 多模态参数
Spring AI 2.0.1
Spring Boot 4.1.x
安全公告 / 合规审计要求升级

(图 2:版本锁链------箭头方向表示"依赖谁",虚线表示"尚未跟进")

这张图就是路线 A 的全部代价。 四行方块串成一条链,任何一次来自上游的安全要求或合规要求,都必须从链条最右端逐级传导回来,而传导速度取决于最慢的那一环。具体到 2026-09 这个时点:

版本线 状态 对你的影响
Spring Boot 3.5.x 2026-06-30 EOL 不再有免费安全补丁;等保/内审可能直接判不合规
Spring Framework 6.2 EOL 同上,且部分漏洞修复只在 7.0 分支
Spring AI 1.1.x 维护分支 修复优先级低于 2.0,新特性不会回移
Spring AI Alibaba 1.1.2.2 可用 版本跟进节奏受上游影响

我特别想把"等保/内审可能直接判不合规"这一条说透 ,因为它不是技术问题,却经常是压死路线 A 的最后一根稻草。在政企与金融场景里,"框架基线是否仍在支持期内"通常是测评项之一 ,一旦被判不合规,留给你的整改时间可能只有几周------而这几周里,你要完成的是"三方版本同时升级 + 业务回归测试",不是"打一个补丁"。 这就是为什么我把它写成"过渡方案"而不是"可选方案"。

结论:路线 A 只适合两种项目------已经在这条线上跑的存量项目(短期不折腾,但要排期升级),以及"已经买了商业支持或已在用平台独有特性、迁移成本高于版本风险"的项目。新项目没有理由从这里开始。


4️⃣ 路线 B:Boot 4.1 + Spring AI 2.0.1 + 兼容端点(推荐)

4.1 一句话理由与它的代价

一句话:你的 Java 代码里不出现任何供应商的名字,换模型只改两个配置值。 在 AI 这个"模型半年一换代"的领域里,这个特性比任何"原生能力"都值钱,因为业务代码的稳定性和模型的可替换性,是你唯一能长期掌控的两件事。

代价也诚实说清楚,一共三条: 一是协议外参数用不了(4.5 给绕行方案);二是部分平台的"应用层能力"(如编排、知识库绑定)确实用不上,那些能力要么在平台侧旁路使用、要么放弃;三是兼容层是多一层的翻译,极端情况下可能出现"参数被忽略而静默使用默认值"的现象 ------排查这类问题的方法仍然是第 2.4 节那张对照表的最后一行:打印完整响应体,看它的实际行为。

4.2 依赖

xml 复制代码
<!-- 与上一篇 3.2 节完全一致,这里只强调"两个不要" -->
<dependencyManagement>
    <dependencies>
        <dependency>
            <groupId>org.springframework.ai</groupId>
            <artifactId>spring-ai-bom</artifactId>
            <version>2.0.1</version>
            <type>pom</type>
            <scope>import</scope>
        </dependency>
    </dependencies>
</dependencyManagement>

<dependencies>
    <dependency>
        <groupId>org.springframework.boot</groupId>
        <artifactId>spring-boot-starter-web</artifactId>
    </dependency>
    <!-- 不要引 spring-ai-core / spring-ai-openai 这类中间模块,只引 starter -->
    <dependency>
        <groupId>org.springframework.ai</groupId>
        <artifactId>spring-ai-starter-model-openai</artifactId>
    </dependency>
    <dependency>
        <groupId>org.springframework.boot</groupId>
        <artifactId>spring-boot-starter-actuator</artifactId>
    </dependency>
</dependencies>

4.3 配置:这里有三个静默失效的坑

yaml 复制代码
# application.yml(路线 B)
spring:
  ai:
    openai:
      base-url: https://dashscope.aliyuncs.com/compatible-mode/v1
      api-key: ${DASHSCOPE_API_KEY}
      chat:
        model: qwen-plus          # 2.0:直挂 chat 下
        temperature: 0.2
        max-tokens: 1024
      connect-timeout: 5000
      read-timeout: 60000

从 1.x 迁过来的团队,下面这张对照表请逐行核对------因为这三处变化都不会报错,只会静默失效。

配置项 1.1.x 写法 2.0 写法 写错的表现
模型名 spring.ai.openai.chat.options.model spring.ai.openai.chat.model 静默使用默认模型,日志无异常,账单却在涨
温度等参数 ...chat.options.temperature ...chat.temperature 同上,参数不生效
超时 键名在不同小版本间有调整 参考当前文档为准 超时仍用默认值,表现为"偶发长尾延迟"

我把"静默失效"单独拎出来讲,是因为它是最贵的一类故障。 抛异常的配置错误五分钟就能修好;静默失效的配置错误可能上线三个月都没人发现 ------它不产生错误日志,只产生"账单比预期高"和"回答风格有点怪"这两个难以归因的现象。防它的办法很土但有效:在应用启动时打一行日志,把最终生效的模型名、超时、温度打出来。 一个 @PostConstruct 十行代码的事,能省掉后面几天的排查。

4.4 三个接口:同步、结构化、流式

java 复制代码
@RestController
@RequestMapping("/api/ai")
public class ChatController {

    private final ChatClient chatClient;

    // 2.0 的推荐写法:注入 auto-configured 的 Builder,
    // 默认值集中在这里定义,业务方法只关心业务
    public ChatController(ChatClient.Builder builder) {
        this.chatClient = builder
                .defaultSystem("你是企业技术支持助手。回答简洁,不确定时明确说不知道。")
                .defaultOptions(ChatOptions.builder()
                        .model("qwen-plus")      // 也可放 yml,两者会合并,注意优先级
                        .temperature(0.2)
                        .maxTokens(1024)
                        .build())
                .build();
    }

    // 1) 同步问答:最常用,也最容易写出"看起来对但不可用"的版本
    @GetMapping("/chat")
    public String chat(@RequestParam String q) {
        return chatClient.prompt().user(q).call().content();
    }

    // 2) 结构化输出:让返回值直接是 Java 对象,而不是一段需要手工解析的文本
    //    这是本篇最该学会的一个能力------它把"模型输出"变成了"可编译的契约"
    public record Ticket(String category, String priority, String summary,
                         List<String> suggestedActions) {}

    @GetMapping("/classify")
    public Ticket classify(@RequestParam String text) {
        return chatClient.prompt()
                .user(u -> u.text("把下面的工单分类并按格式返回:\n{input}")
                            .param("input", text))
                .call()
                .entity(Ticket.class);
    }

    // 3) 流式输出:注意返回类型是 Flux<String>,不是 String
    @GetMapping(value = "/chat/stream", produces = MediaType.TEXT_EVENT_STREAM_VALUE)
    public Flux<String> stream(@RequestParam String q) {
        return chatClient.prompt().user(q).stream().content();
    }
}

这三段代码里有四个值得停一下的点。

第一,defaultOptions 与 yml 的合并优先级。 两者都能设模型名,当同时存在时以"更靠近调用点"的为准 。这里推荐的做法是:所有"环境相关"的值(模型名、超时)放 yml,所有"业务相关"的值(温度、提示词风格)放代码 ,因为前者跟环境走、后者跟版本走。把它们混在一起的团队,典型症状是"改 yml 不生效"或"改代码要重新发版才能调参"。

第二,为什么结构化输出要单独给一个接口。 因为**"让模型输出 JSON"和"让模型在 100 次里 100 次都输出合法 JSON"是两件事**。前者的做法是在提示词里求它,后者需要框架层的校验与修复。entity(Ticket.class) 背后是转换器 + 校验 Advisor 的组合 ,它给你的最重要的东西不是方便,而是"失败时的行为是明确的" ------校验不过会抛出可识别的异常,而不是给你一段"抱歉,我无法按格式返回"的文本,让你的 readValue 在线上炸掉。这一条在上一篇的 1.2 节里我作为"手写派的终点"讲过,这里就是它的解药。

第三,.user(u -> u.text(...).param(...)) 这个新写法值得记住。 它让"提示词模板"和"业务变量"分离,好处不是代码好看,而是安全 :用户输入通过参数注入而不是字符串拼接,降低了"用户输入直接改写指令"的提示注入风险 (系列一第 7 篇的完整版)。注意它降低的是"结构被改写"的风险,不是消除------模型层面的注入仍需靠系统提示与输出校验共同防御。

第四,流式的返回类型必须是 Flux<String>,且 produces 要声明 text/event-stream。 少任何一项,前端就收不到流。这一点在本地测试时经常被忽略 ------你如果用 curl 直接看,会看到一行一行的 data:;而如果用浏览器或某些网关,可能会被缓冲成一整块 ,那就是第 8 篇要处理的 proxy_buffering 问题。

4.5 兼容模式的真实代价:协议外参数怎么办

这一节是路线 B 唯一需要动脑筋的地方,我只讲方法与结论,不编造具体参数名。

OpenAI 协议里没有"联网搜索"这类平台私有能力。当你的业务确实需要它时,有三种绕行方式,按推荐度排序:

方案一:不用它。 听起来像逃避,但在多数场景下是正解 ------"让模型回答最新信息"这件事,用 RAG 检索你自己的可信文档往往比让它联网搜更可控 (可控性、可审计性、答案可追溯性都更好)。而且这正是第 4 篇的主题。 我把它排在第一,是因为我看到太多团队为了"能联网"放弃了整个解耦架构,最后发现业务真正需要的是"知道公司内部的最新规定",而那根本不是联网能解决的。

方案二:用协议允许的"扩展字段"传递。 部分兼容端点在实现上允许在请求体里附加非标准字段(各家支持程度不同,必须以官方文档为准 )。这条路的关键问题是"它绕过了框架的类型安全" ------你要么自己拼请求体,要么用框架提供的参数扩展点。如果你走这条路,请务必写清注释与单测 ,因为它是你工程里唯一一处"编译期保护失效"的地方,未来某次升级很容易踩空。

方案三:对这几个接口退回原生 SDK。 这是最重但最稳的做法:主干继续用兼容模式,只把需要平台能力的少数接口用原生 ChatModel 实现。 具体落地关键在"Bean 不冲突",见 Q3。

我的结论是:优先方案一,其次方案三,方案二只在"参数极少且官方明确支持"时使用。 判断依据很简单------方案二省下的是几行代码,付出的是工程的确定性;方案三付出的是一个显式的隔离边界,换来的是架构清晰。

4.6 从 1.1.x 迁到 2.0 的最小检查清单

如果你手上已经有 1.1.x 的项目,升 2.0 时按这个清单过一遍,能挡掉大部分问题:

  1. 配置 :.options 中间段是否已去掉(4.3 的对照表)。
  2. 依赖 :mvn dependency:tree | grep spring-ai 是否只有单一版本;有没有误引中间模块。
  3. 工具调用 :2.0 把工具循环抬到了 Advisor 层,如果你之前自己写过"解析工具调用 → 执行 → 回填"的循环,现在应该删掉,否则会双写、重复执行。
  4. JSON 处理 :2.0 换了 Jackson 3,自定义 ObjectMapper 的注册方式、以及依赖它的序列化配置需要复核。
  5. 观测 :指标名与标签可能有调整,先确认你的看板不是空的(升级后最容易被忽略的回归)。
  6. 回归重点 :优先回归"结构化输出"和"工具调用"两类接口------它们是升级里改动最深、也最容易在测试里蒙混过去的。

5️⃣ 跑通之后,立刻要做的四件事

很多教程到"接口返回了一句话"就结束了。但那一刻你的接口还处在"能演示、不能上线"的状态 ------它的 Key 明文躺在配置文件里,它没有超时上限,它被限流时会把异常直接甩给用户,它的 token 消耗无人知晓。这四件事加起来不超过半天,却决定了你这个接口三个月后是"稳定运行"还是"天天救火"。

5.1 第一件:把 Key 从配置文件里赶出去

先说一个很多人不知道的严肃事实:一旦密钥被提交进 Git,即使你下一个 commit 删掉它,也必须视为"已泄露"。 因为 Git 的历史是可检索的------只要仓库被 clone 过、被镜像过、或者被任何有权限的人拉取过,那个 Key 就永久留在别人的本地历史里了。 正确的处置只有一条:立即在控制台吊销该 Key 并重新签发,然后才去处理代码。 我见过团队把"删掉那一行"当成了修复,而那个 Key 一直在生效,直到三个月后被安全扫描发现。

Key 的存放位置应该是一个四级漏斗:

环境 推荐方式 关键要求
本地开发 环境变量(IDE 运行配置或 .env + spring.config.import) .env 必须进 .gitignore ,并提交一份 .env.example 作模板
CI / 测试 流水线密钥变量(Secret) 只读权限,且日志里不能回显(很多 CI 默认会打印环境变量,要显式屏蔽)
生产(虚拟机) 配置中心或密钥管理服务 支持热更新与审计;不要放在应用目录里的明文文件
生产(K8s) Secret + 外部密钥注入 见系列一第 12 篇容器化部署

这里有一个我很坚持的设计判断:密钥缺失时,宁可让应用启动失败,也不要让它"带着空 Key 去调远程"。 后者看起来更"健壮"(不崩溃),实际是把一个配置错误推迟到了第一次真实请求才暴露 ,而那时候可能已经是上线后的高峰期。用 @NotBlank + @Validated 的配置类做启动校验,是这个判断最便宜的落地方式:

java 复制代码
// 启动即校验:缺 Key 直接起不来,而不是运行期静默失败
@ConfigurationProperties(prefix = "spring.ai.openai")
@Validated
public record AiProperties(
        @NotBlank String apiKey,
        @NotBlank String baseUrl,
        @NotBlank String model) {}

还有一个细节值得单说:Key 的轮换。 由于 Key 只显示一次,轮换时如果"先删旧再建新",中间会有一段服务不可用的窗口 。正确顺序是:新建一个 Key → 双 Key 并存(应用改配置、灰度生效)→ 确认旧 Key 无调用后 → 再吊销旧 Key。 这个顺序在多实例部署时尤其重要------滚动更新过程中必然存在"新旧配置并存"的时段,如果旧 Key 已经被吊销,那段时间的请求会成片失败。
#mermaid-svg-cDGsI25BdY6fO4l6{font-family:"trebuchet ms",verdana,arial,sans-serif;font-size:16px;fill:#333;}@keyframes edge-animation-frame{from{stroke-dashoffset:0;}}@keyframes dash{to{stroke-dashoffset:0;}}#mermaid-svg-cDGsI25BdY6fO4l6 .edge-animation-slow{stroke-dasharray:9,5!important;stroke-dashoffset:900;animation:dash 50s linear infinite;stroke-linecap:round;}#mermaid-svg-cDGsI25BdY6fO4l6 .edge-animation-fast{stroke-dasharray:9,5!important;stroke-dashoffset:900;animation:dash 20s linear infinite;stroke-linecap:round;}#mermaid-svg-cDGsI25BdY6fO4l6 .error-icon{fill:#552222;}#mermaid-svg-cDGsI25BdY6fO4l6 .error-text{fill:#552222;stroke:#552222;}#mermaid-svg-cDGsI25BdY6fO4l6 .edge-thickness-normal{stroke-width:1px;}#mermaid-svg-cDGsI25BdY6fO4l6 .edge-thickness-thick{stroke-width:3.5px;}#mermaid-svg-cDGsI25BdY6fO4l6 .edge-pattern-solid{stroke-dasharray:0;}#mermaid-svg-cDGsI25BdY6fO4l6 .edge-thickness-invisible{stroke-width:0;fill:none;}#mermaid-svg-cDGsI25BdY6fO4l6 .edge-pattern-dashed{stroke-dasharray:3;}#mermaid-svg-cDGsI25BdY6fO4l6 .edge-pattern-dotted{stroke-dasharray:2;}#mermaid-svg-cDGsI25BdY6fO4l6 .marker{fill:#333333;stroke:#333333;}#mermaid-svg-cDGsI25BdY6fO4l6 .marker.cross{stroke:#333333;}#mermaid-svg-cDGsI25BdY6fO4l6 svg{font-family:"trebuchet ms",verdana,arial,sans-serif;font-size:16px;}#mermaid-svg-cDGsI25BdY6fO4l6 p{margin:0;}#mermaid-svg-cDGsI25BdY6fO4l6 .label{font-family:"trebuchet ms",verdana,arial,sans-serif;color:#333;}#mermaid-svg-cDGsI25BdY6fO4l6 .cluster-label text{fill:#333;}#mermaid-svg-cDGsI25BdY6fO4l6 .cluster-label span{color:#333;}#mermaid-svg-cDGsI25BdY6fO4l6 .cluster-label span p{background-color:transparent;}#mermaid-svg-cDGsI25BdY6fO4l6 .label text,#mermaid-svg-cDGsI25BdY6fO4l6 span{fill:#333;color:#333;}#mermaid-svg-cDGsI25BdY6fO4l6 .node rect,#mermaid-svg-cDGsI25BdY6fO4l6 .node circle,#mermaid-svg-cDGsI25BdY6fO4l6 .node ellipse,#mermaid-svg-cDGsI25BdY6fO4l6 .node polygon,#mermaid-svg-cDGsI25BdY6fO4l6 .node path{fill:#ECECFF;stroke:#9370DB;stroke-width:1px;}#mermaid-svg-cDGsI25BdY6fO4l6 .rough-node .label text,#mermaid-svg-cDGsI25BdY6fO4l6 .node .label text,#mermaid-svg-cDGsI25BdY6fO4l6 .image-shape .label,#mermaid-svg-cDGsI25BdY6fO4l6 .icon-shape .label{text-anchor:middle;}#mermaid-svg-cDGsI25BdY6fO4l6 .node .katex path{fill:#000;stroke:#000;stroke-width:1px;}#mermaid-svg-cDGsI25BdY6fO4l6 .rough-node .label,#mermaid-svg-cDGsI25BdY6fO4l6 .node .label,#mermaid-svg-cDGsI25BdY6fO4l6 .image-shape .label,#mermaid-svg-cDGsI25BdY6fO4l6 .icon-shape .label{text-align:center;}#mermaid-svg-cDGsI25BdY6fO4l6 .node.clickable{cursor:pointer;}#mermaid-svg-cDGsI25BdY6fO4l6 .root .anchor path{fill:#333333!important;stroke-width:0;stroke:#333333;}#mermaid-svg-cDGsI25BdY6fO4l6 .arrowheadPath{fill:#333333;}#mermaid-svg-cDGsI25BdY6fO4l6 .edgePath .path{stroke:#333333;stroke-width:2.0px;}#mermaid-svg-cDGsI25BdY6fO4l6 .flowchart-link{stroke:#333333;fill:none;}#mermaid-svg-cDGsI25BdY6fO4l6 .edgeLabel{background-color:rgba(232,232,232, 0.8);text-align:center;}#mermaid-svg-cDGsI25BdY6fO4l6 .edgeLabel p{background-color:rgba(232,232,232, 0.8);}#mermaid-svg-cDGsI25BdY6fO4l6 .edgeLabel rect{opacity:0.5;background-color:rgba(232,232,232, 0.8);fill:rgba(232,232,232, 0.8);}#mermaid-svg-cDGsI25BdY6fO4l6 .labelBkg{background-color:rgba(232, 232, 232, 0.5);}#mermaid-svg-cDGsI25BdY6fO4l6 .cluster rect{fill:#ffffde;stroke:#aaaa33;stroke-width:1px;}#mermaid-svg-cDGsI25BdY6fO4l6 .cluster text{fill:#333;}#mermaid-svg-cDGsI25BdY6fO4l6 .cluster span{color:#333;}#mermaid-svg-cDGsI25BdY6fO4l6 div.mermaidTooltip{position:absolute;text-align:center;max-width:200px;padding:2px;font-family:"trebuchet ms",verdana,arial,sans-serif;font-size:12px;background:hsl(80, 100%, 96.2745098039%);border:1px solid #aaaa33;border-radius:2px;pointer-events:none;z-index:100;}#mermaid-svg-cDGsI25BdY6fO4l6 .flowchartTitleText{text-anchor:middle;font-size:18px;fill:#333;}#mermaid-svg-cDGsI25BdY6fO4l6 rect.text{fill:none;stroke-width:0;}#mermaid-svg-cDGsI25BdY6fO4l6 .icon-shape,#mermaid-svg-cDGsI25BdY6fO4l6 .image-shape{background-color:rgba(232,232,232, 0.8);text-align:center;}#mermaid-svg-cDGsI25BdY6fO4l6 .icon-shape p,#mermaid-svg-cDGsI25BdY6fO4l6 .image-shape p{background-color:rgba(232,232,232, 0.8);padding:2px;}#mermaid-svg-cDGsI25BdY6fO4l6 .icon-shape .label rect,#mermaid-svg-cDGsI25BdY6fO4l6 .image-shape .label rect{opacity:0.5;background-color:rgba(232,232,232, 0.8);fill:rgba(232,232,232, 0.8);}#mermaid-svg-cDGsI25BdY6fO4l6 .label-icon{display:inline-block;height:1em;overflow:visible;vertical-align:-0.125em;}#mermaid-svg-cDGsI25BdY6fO4l6 .node .label-icon path{fill:currentColor;stroke:revert;stroke-width:revert;}#mermaid-svg-cDGsI25BdY6fO4l6 :root{--mermaid-font-family:"trebuchet ms",verdana,arial,sans-serif;} 轮换时序
任何一步发现密钥曾进过 Git
存储
.env(本地)

必进 .gitignore
CI/CD Secret

屏蔽日志回显
配置中心 / 密钥管理

可审计 + 热更新
K8s Secret

外部注入
步骤1:新建 Key B

旧 Key A 保持有效
步骤2:配置切到 B

灰度 / 滚动生效
步骤3:观察 24h

确认 A 的调用量归零
步骤4:吊销 Key A
必须先吊销再造新的

历史 commit 无法真正抹除

(图 3:Key 的四级存放与轮换时序)

5.2 第二件:给第一个接口加"三件套"

我不建议你在第一个版本里就堆一堆治理组件,但这三样必须有,因为它们的缺失会在最坏的时刻暴露: 超时缺失 → 一个慢请求占住线程直到线程池耗尽;重试缺失 → 一次瞬时网络抖动变成一个用户可见的报错;限流缺失 → 一次异常流量把你的额度打光,而且这类故障往往是"自己打自己"(比如前端某个 bug 触发了请求风暴)。

先说超时,这里要区分三个不同层面的超时,很多人只设了一个:

  • 连接超时(connect-timeout) :建 TCP 连接的时间。建议 3~5 秒。 这个值不需要大,因为连不上就是连不上。
  • 读超时(read-timeout) :等待响应数据的时间。建议 60 秒起步 ------注意这是一个"非流式请求下必须等完整回答生成完"的等待时间,长回答很容易超过 30 秒。
  • 首 token 超时 :如果你开了流式,真正该限制的是"第一个 token 多久没来" ,而不是总时长(总时长受回答长度影响,没有固定上限)。很多框架不直接提供这个维度,需要自己在流上做一层 timeout() 包装。 这一点在第 8 篇会展开。

再说重试,它在 AI 场景里是把双刃剑,我把判断规则写死:

情况 是否重试 理由
连接失败、DNS 失败 ✅ 重试 典型瞬时故障,重试成功率高
502 / 503 / 504 ✅ 重试(带退避) 服务端瞬时不可用;但 504 要谨慎,见下
429 限流 ⚠️ 必须带退避,且次数要少 立即重试等于自己加剧限流,是最常见的雪崩起点
400 / 401 / 403 ❌ 不重试 参数或凭据错误,重试 100 次也一样
流式响应已开始输出 ❌ 绝不重试 用户已经看到一半内容,重试会造成重复输出与 token 双倍消耗
工具调用已执行 ❌ 绝不自动重试 重试 = 重复执行写操作(系列一第 11 篇的同一条纪律)

这张表里最后两行是 AI 场景特有的,也是普通后端经验完全无法覆盖的地方。 在传统后端里,GET 请求重试几乎是免费的;但在 AI 接口上,"一次重试"的代价是双倍 token、双倍延迟,而如果这个请求已经触发了工具调用,代价就是"订单被重复创建"。 我给的参数是:最多重试 2 次,退避间隔 500ms 起、指数增长、叠加随机抖动。

最后说限流,这里有一个反直觉的结论:限流的目标不是"排队",而是"快速失败"。 传统思路是超限就排队等一会儿,但在 AI 场景里,排队会把延迟叠加成雪崩 ------因为单个请求本来就慢(几秒到几十秒),排队意味着线程和连接被长时间占用,最后的结果是"所有请求都超时",而不是"部分请求慢一点"。 所以我的建议是:超过并发上限就直接返回一个明确的降级响应(比如"当前咨询人数较多,请稍后再试"),而不是让它等。 宁可让 10% 的用户马上知道要重试,也不要让 100% 的用户一起等死。
#mermaid-svg-7lj5O3mLn1UXaDj2{font-family:"trebuchet ms",verdana,arial,sans-serif;font-size:16px;fill:#333;}@keyframes edge-animation-frame{from{stroke-dashoffset:0;}}@keyframes dash{to{stroke-dashoffset:0;}}#mermaid-svg-7lj5O3mLn1UXaDj2 .edge-animation-slow{stroke-dasharray:9,5!important;stroke-dashoffset:900;animation:dash 50s linear infinite;stroke-linecap:round;}#mermaid-svg-7lj5O3mLn1UXaDj2 .edge-animation-fast{stroke-dasharray:9,5!important;stroke-dashoffset:900;animation:dash 20s linear infinite;stroke-linecap:round;}#mermaid-svg-7lj5O3mLn1UXaDj2 .error-icon{fill:#552222;}#mermaid-svg-7lj5O3mLn1UXaDj2 .error-text{fill:#552222;stroke:#552222;}#mermaid-svg-7lj5O3mLn1UXaDj2 .edge-thickness-normal{stroke-width:1px;}#mermaid-svg-7lj5O3mLn1UXaDj2 .edge-thickness-thick{stroke-width:3.5px;}#mermaid-svg-7lj5O3mLn1UXaDj2 .edge-pattern-solid{stroke-dasharray:0;}#mermaid-svg-7lj5O3mLn1UXaDj2 .edge-thickness-invisible{stroke-width:0;fill:none;}#mermaid-svg-7lj5O3mLn1UXaDj2 .edge-pattern-dashed{stroke-dasharray:3;}#mermaid-svg-7lj5O3mLn1UXaDj2 .edge-pattern-dotted{stroke-dasharray:2;}#mermaid-svg-7lj5O3mLn1UXaDj2 .marker{fill:#333333;stroke:#333333;}#mermaid-svg-7lj5O3mLn1UXaDj2 .marker.cross{stroke:#333333;}#mermaid-svg-7lj5O3mLn1UXaDj2 svg{font-family:"trebuchet ms",verdana,arial,sans-serif;font-size:16px;}#mermaid-svg-7lj5O3mLn1UXaDj2 p{margin:0;}#mermaid-svg-7lj5O3mLn1UXaDj2 .label{font-family:"trebuchet ms",verdana,arial,sans-serif;color:#333;}#mermaid-svg-7lj5O3mLn1UXaDj2 .cluster-label text{fill:#333;}#mermaid-svg-7lj5O3mLn1UXaDj2 .cluster-label span{color:#333;}#mermaid-svg-7lj5O3mLn1UXaDj2 .cluster-label span p{background-color:transparent;}#mermaid-svg-7lj5O3mLn1UXaDj2 .label text,#mermaid-svg-7lj5O3mLn1UXaDj2 span{fill:#333;color:#333;}#mermaid-svg-7lj5O3mLn1UXaDj2 .node rect,#mermaid-svg-7lj5O3mLn1UXaDj2 .node circle,#mermaid-svg-7lj5O3mLn1UXaDj2 .node ellipse,#mermaid-svg-7lj5O3mLn1UXaDj2 .node polygon,#mermaid-svg-7lj5O3mLn1UXaDj2 .node path{fill:#ECECFF;stroke:#9370DB;stroke-width:1px;}#mermaid-svg-7lj5O3mLn1UXaDj2 .rough-node .label text,#mermaid-svg-7lj5O3mLn1UXaDj2 .node .label text,#mermaid-svg-7lj5O3mLn1UXaDj2 .image-shape .label,#mermaid-svg-7lj5O3mLn1UXaDj2 .icon-shape .label{text-anchor:middle;}#mermaid-svg-7lj5O3mLn1UXaDj2 .node .katex path{fill:#000;stroke:#000;stroke-width:1px;}#mermaid-svg-7lj5O3mLn1UXaDj2 .rough-node .label,#mermaid-svg-7lj5O3mLn1UXaDj2 .node .label,#mermaid-svg-7lj5O3mLn1UXaDj2 .image-shape .label,#mermaid-svg-7lj5O3mLn1UXaDj2 .icon-shape .label{text-align:center;}#mermaid-svg-7lj5O3mLn1UXaDj2 .node.clickable{cursor:pointer;}#mermaid-svg-7lj5O3mLn1UXaDj2 .root .anchor path{fill:#333333!important;stroke-width:0;stroke:#333333;}#mermaid-svg-7lj5O3mLn1UXaDj2 .arrowheadPath{fill:#333333;}#mermaid-svg-7lj5O3mLn1UXaDj2 .edgePath .path{stroke:#333333;stroke-width:2.0px;}#mermaid-svg-7lj5O3mLn1UXaDj2 .flowchart-link{stroke:#333333;fill:none;}#mermaid-svg-7lj5O3mLn1UXaDj2 .edgeLabel{background-color:rgba(232,232,232, 0.8);text-align:center;}#mermaid-svg-7lj5O3mLn1UXaDj2 .edgeLabel p{background-color:rgba(232,232,232, 0.8);}#mermaid-svg-7lj5O3mLn1UXaDj2 .edgeLabel rect{opacity:0.5;background-color:rgba(232,232,232, 0.8);fill:rgba(232,232,232, 0.8);}#mermaid-svg-7lj5O3mLn1UXaDj2 .labelBkg{background-color:rgba(232, 232, 232, 0.5);}#mermaid-svg-7lj5O3mLn1UXaDj2 .cluster rect{fill:#ffffde;stroke:#aaaa33;stroke-width:1px;}#mermaid-svg-7lj5O3mLn1UXaDj2 .cluster text{fill:#333;}#mermaid-svg-7lj5O3mLn1UXaDj2 .cluster span{color:#333;}#mermaid-svg-7lj5O3mLn1UXaDj2 div.mermaidTooltip{position:absolute;text-align:center;max-width:200px;padding:2px;font-family:"trebuchet ms",verdana,arial,sans-serif;font-size:12px;background:hsl(80, 100%, 96.2745098039%);border:1px solid #aaaa33;border-radius:2px;pointer-events:none;z-index:100;}#mermaid-svg-7lj5O3mLn1UXaDj2 .flowchartTitleText{text-anchor:middle;font-size:18px;fill:#333;}#mermaid-svg-7lj5O3mLn1UXaDj2 rect.text{fill:none;stroke-width:0;}#mermaid-svg-7lj5O3mLn1UXaDj2 .icon-shape,#mermaid-svg-7lj5O3mLn1UXaDj2 .image-shape{background-color:rgba(232,232,232, 0.8);text-align:center;}#mermaid-svg-7lj5O3mLn1UXaDj2 .icon-shape p,#mermaid-svg-7lj5O3mLn1UXaDj2 .image-shape p{background-color:rgba(232,232,232, 0.8);padding:2px;}#mermaid-svg-7lj5O3mLn1UXaDj2 .icon-shape .label rect,#mermaid-svg-7lj5O3mLn1UXaDj2 .image-shape .label rect{opacity:0.5;background-color:rgba(232,232,232, 0.8);fill:rgba(232,232,232, 0.8);}#mermaid-svg-7lj5O3mLn1UXaDj2 .label-icon{display:inline-block;height:1em;overflow:visible;vertical-align:-0.125em;}#mermaid-svg-7lj5O3mLn1UXaDj2 .node .label-icon path{fill:currentColor;stroke:revert;stroke-width:revert;}#mermaid-svg-7lj5O3mLn1UXaDj2 :root{--mermaid-font-family:"trebuchet ms",verdana,arial,sans-serif;} 超限
通过
失败
成功
超时
返回
400/401/403
429
2xx
2xx 且已触发工具调用
是且未超次数
否
请求进入
并发/令牌桶

未超限?
快速失败 + 明确提示

不排队(排队 → 全员超时)
连接建立

3~5s 内成功?
可重试?
等待响应

read-timeout 内?
响应类型
不重试,直接报错

参数/凭据问题
指数退避 + 抖动

最多 2 次(立即重试会加剧限流)
成功
禁止自动重试

重复执行写操作
返回失败

(图 4:三件套的请求流------注意"超限不排队"与"工具调用后不重试"这两个 AI 场景特有的判断)

🖼️ 配图说明(决策流程信息图 | 建议文件名:b2_three_gates.png)

画面内容:采用参考图 5 的决策流程图版式,把"该不该重试"画成一条判定链。首行 3 个蓝边节点用编号箭头串起:「请求失败」→「先看是哪一类失败」→「进入重试判定」。中央菱形判断节点内写「失败类型是什么」。菱形下分出四条分支,各通向一张结果卡:绿色分支「502 / 503 / 504」「重试,带退避」;绿偏橙分支「429 限流」「重试必须带退避,次数要少------立即重试等于自己加剧限流」;橙色分支「400 / 401 / 403」「不重试,参数与凭据问题重试一百次也一样」;红色分支「流式已开始输出 / 工具已执行」「绝不重试:用户已看到一半,重试就是重复输出与双倍 token」,这一张卡额外加红框与禁止符号。底部放两块虚线子链路图:左框「三道闸门的位置」→「超时 → 重试 → 快速失败(不排队)」;右框「退避曲线」→ 一条一次比一次短的折返箭头,下方标注「第一次 1 秒内,第二次更短」。最底部两行图例:绿线=可重试,红线=绝不重试,橙线=带条件重试。

画面文字清单(生成后可直接排版覆盖,若生图中文有笔画错误就照此重打):请求失败、先看是哪一类失败、进入重试判定、失败类型是什么、502 / 503 / 504、重试,带退避、429 限流、重试必须带退避,次数要少------立即重试等于自己加剧限流、400 / 401 / 403、不重试,参数与凭据问题重试一百次也一样、流式已开始输出 / 工具已执行、绝不重试:用户已看到一半,重试就是重复输出与双倍 token、三道闸门的位置、超时 → 重试 → 快速失败(不排队)、退避曲线、第一次 1 秒内,第二次更短、可重试、绝不重试、带条件重试

GPT-image-2 提示词(英文,可直接复制):

text 复制代码
Decision-flow diagram about retry policy for an AI API call, three blue outlined rounded nodes in the top row joined by numbered arrows, a central blue diamond decision node with a Chinese question label, four diverging branches in green, orange and red leading to rounded result cards, the red card outlined and carrying a prohibition symbol, two dashed-border sub-diagram boxes at the bottom, one of them holding a successively shorter return arrow, a three-row legend strip at the very bottom with green, red and orange line samples, clean minimal decision-flow diagram on a pure white background, three blue outlined rounded process nodes in the top row joined by thin numbered arrows, one central blue diamond decision node with a signal icon, green and orange diverging branches leading to rounded result cards with check and warning icons, two dashed-border sub-diagram boxes at the bottom, a two-row legend strip with colored line samples and explanatory text, flat vector, all Chinese labels rendered sharply in a clean bold sans-serif typeface with uniform label size and correct strokes, no watermark, no signature

5.3 第三件:可观测------让 token 和延迟自己说话

好消息是这件事在 Spring 技术栈里几乎是免费的 :Spring AI 通过 Micrometer 把模型调用接进了 Spring Boot 的观测体系,你不需要自己埋点,只需要打开 Actuator 的端点,就能拿到 gen_ai.* 系列的指标(具体指标名与标签以当前版本文档为准)。

但"有指标"和"看得懂"是两件事。我建议你从上线的第一天就固定看三组数:

  • 延迟组 :首 token 延迟 与总耗时 ,都要看 P50 和 P95。这两个数必须分开看 ------它们的优化手段完全不同 :首 token 延迟由"提示词长度、模型档位、路由距离"决定;总耗时由"输出长度、max_tokens、是否流式"决定。只看一个平均值,你会既优化错方向,也不知道该优化哪个。
  • 用量组 :请求数、prompt_tokens、completion_tokens。prompt_tokens 通常是增长最快的那个,因为对话历史和检索片段都会累积进去。
  • 质量组 :异常率、结构化输出校验失败率、工具调用次数。校验失败率是提示词质量恶化的第一批信号------它一涨,说明输入分布变了或提示词被改坏了。

还有一条实践性的建议:日志里不要打完整的请求与响应体。 两个原因:一是体积 (一次 RAG 请求的上下文动辄上万 token,全量打日志会让日志系统成本翻倍);二是合规 (用户的提问与模型的回答里很可能有个人信息、订单号、内部数据)。我的做法是只记元数据 ------会话 ID、模型名、token 数、耗时、输入输出的长度与哈希值。哈希值是"可关联但不可读"的关键设计 :当用户投诉"它回答错了"时,你可以用哈希把那次请求找出来重放,而不需要在日志里存明文。
Micrometer / 日志 模型服务 HTTP 客户端 Advisor 链 Controller 调用方 Micrometer / 日志 模型服务 HTTP 客户端 Advisor 链 Controller 调用方 #mermaid-svg-dZwJ5PbppDIX0rUO{font-family:"trebuchet ms",verdana,arial,sans-serif;font-size:16px;fill:#333;}@keyframes edge-animation-frame{from{stroke-dashoffset:0;}}@keyframes dash{to{stroke-dashoffset:0;}}#mermaid-svg-dZwJ5PbppDIX0rUO .edge-animation-slow{stroke-dasharray:9,5!important;stroke-dashoffset:900;animation:dash 50s linear infinite;stroke-linecap:round;}#mermaid-svg-dZwJ5PbppDIX0rUO .edge-animation-fast{stroke-dasharray:9,5!important;stroke-dashoffset:900;animation:dash 20s linear infinite;stroke-linecap:round;}#mermaid-svg-dZwJ5PbppDIX0rUO .error-icon{fill:#552222;}#mermaid-svg-dZwJ5PbppDIX0rUO .error-text{fill:#552222;stroke:#552222;}#mermaid-svg-dZwJ5PbppDIX0rUO .edge-thickness-normal{stroke-width:1px;}#mermaid-svg-dZwJ5PbppDIX0rUO .edge-thickness-thick{stroke-width:3.5px;}#mermaid-svg-dZwJ5PbppDIX0rUO .edge-pattern-solid{stroke-dasharray:0;}#mermaid-svg-dZwJ5PbppDIX0rUO .edge-thickness-invisible{stroke-width:0;fill:none;}#mermaid-svg-dZwJ5PbppDIX0rUO .edge-pattern-dashed{stroke-dasharray:3;}#mermaid-svg-dZwJ5PbppDIX0rUO .edge-pattern-dotted{stroke-dasharray:2;}#mermaid-svg-dZwJ5PbppDIX0rUO .marker{fill:#333333;stroke:#333333;}#mermaid-svg-dZwJ5PbppDIX0rUO .marker.cross{stroke:#333333;}#mermaid-svg-dZwJ5PbppDIX0rUO svg{font-family:"trebuchet ms",verdana,arial,sans-serif;font-size:16px;}#mermaid-svg-dZwJ5PbppDIX0rUO p{margin:0;}#mermaid-svg-dZwJ5PbppDIX0rUO .actor{stroke:hsl(259.6261682243, 59.7765363128%, 87.9019607843%);fill:#ECECFF;}#mermaid-svg-dZwJ5PbppDIX0rUO text.actor>tspan{fill:black;stroke:none;}#mermaid-svg-dZwJ5PbppDIX0rUO .actor-line{stroke:hsl(259.6261682243, 59.7765363128%, 87.9019607843%);}#mermaid-svg-dZwJ5PbppDIX0rUO .innerArc{stroke-width:1.5;stroke-dasharray:none;}#mermaid-svg-dZwJ5PbppDIX0rUO .messageLine0{stroke-width:1.5;stroke-dasharray:none;stroke:#333;}#mermaid-svg-dZwJ5PbppDIX0rUO .messageLine1{stroke-width:1.5;stroke-dasharray:2,2;stroke:#333;}#mermaid-svg-dZwJ5PbppDIX0rUO #arrowhead path{fill:#333;stroke:#333;}#mermaid-svg-dZwJ5PbppDIX0rUO .sequenceNumber{fill:white;}#mermaid-svg-dZwJ5PbppDIX0rUO #sequencenumber{fill:#333;}#mermaid-svg-dZwJ5PbppDIX0rUO #crosshead path{fill:#333;stroke:#333;}#mermaid-svg-dZwJ5PbppDIX0rUO .messageText{fill:#333;stroke:none;}#mermaid-svg-dZwJ5PbppDIX0rUO .labelBox{stroke:hsl(259.6261682243, 59.7765363128%, 87.9019607843%);fill:#ECECFF;}#mermaid-svg-dZwJ5PbppDIX0rUO .labelText,#mermaid-svg-dZwJ5PbppDIX0rUO .labelText>tspan{fill:black;stroke:none;}#mermaid-svg-dZwJ5PbppDIX0rUO .loopText,#mermaid-svg-dZwJ5PbppDIX0rUO .loopText>tspan{fill:black;stroke:none;}#mermaid-svg-dZwJ5PbppDIX0rUO .loopLine{stroke-width:2px;stroke-dasharray:2,2;stroke:hsl(259.6261682243, 59.7765363128%, 87.9019607843%);fill:hsl(259.6261682243, 59.7765363128%, 87.9019607843%);}#mermaid-svg-dZwJ5PbppDIX0rUO .note{stroke:#aaaa33;fill:#fff5ad;}#mermaid-svg-dZwJ5PbppDIX0rUO .noteText,#mermaid-svg-dZwJ5PbppDIX0rUO .noteText>tspan{fill:black;stroke:none;}#mermaid-svg-dZwJ5PbppDIX0rUO .activation0{fill:#f4f4f4;stroke:#666;}#mermaid-svg-dZwJ5PbppDIX0rUO .activation1{fill:#f4f4f4;stroke:#666;}#mermaid-svg-dZwJ5PbppDIX0rUO .activation2{fill:#f4f4f4;stroke:#666;}#mermaid-svg-dZwJ5PbppDIX0rUO .actorPopupMenu{position:absolute;}#mermaid-svg-dZwJ5PbppDIX0rUO .actorPopupMenuPanel{position:absolute;fill:#ECECFF;box-shadow:0px 8px 16px 0px rgba(0,0,0,0.2);filter:drop-shadow(3px 5px 2px rgb(0 0 0 / 0.4));}#mermaid-svg-dZwJ5PbppDIX0rUO .actor-man line{stroke:hsl(259.6261682243, 59.7765363128%, 87.9019607843%);fill:#ECECFF;}#mermaid-svg-dZwJ5PbppDIX0rUO .actor-man circle,#mermaid-svg-dZwJ5PbppDIX0rUO line{stroke:hsl(259.6261682243, 59.7765363128%, 87.9019607843%);fill:#ECECFF;stroke-width:2px;}#mermaid-svg-dZwJ5PbppDIX0rUO :root{--mermaid-font-family:"trebuchet ms",verdana,arial,sans-serif;} 首 token 延迟从这里开始计时 落库:模型名 / token / 首token延迟 / 总耗时 / 成功与否 不落明文 body,只落长度与哈希 业务请求(含 sessionId) 记录 traceId + sessionId(MDC) 组装 Prompt(系统提示 + 历史 + 用户输入) 前置 Advisor:日志、参数装配 发起调用 POST /chat/completions 首 token 返回 流式 / 整体响应 usage(prompt / completion tokens) 后置 Advisor:结构化校验、异常包装 结果或明确异常 业务响应

(图 5:请求全链路时序------注意"首 token 延迟"与"总耗时"是两个独立的观测点,以及日志只落元数据的原则)

5.4 第四件:把账单变成可预测的数

这一节想解决一个很实际的问题:为什么第一次看到账单时,团队的反应总是"怎么会这么多"。 原因是**prompt_tokens 的构成被系统性低估了**。一次看起来"用户只问了 20 个字"的请求,实际送进去的是:
#mermaid-svg-fs4rzLgsc2MfG5ur{font-family:"trebuchet ms",verdana,arial,sans-serif;font-size:16px;fill:#333;}@keyframes edge-animation-frame{from{stroke-dashoffset:0;}}@keyframes dash{to{stroke-dashoffset:0;}}#mermaid-svg-fs4rzLgsc2MfG5ur .edge-animation-slow{stroke-dasharray:9,5!important;stroke-dashoffset:900;animation:dash 50s linear infinite;stroke-linecap:round;}#mermaid-svg-fs4rzLgsc2MfG5ur .edge-animation-fast{stroke-dasharray:9,5!important;stroke-dashoffset:900;animation:dash 20s linear infinite;stroke-linecap:round;}#mermaid-svg-fs4rzLgsc2MfG5ur .error-icon{fill:#552222;}#mermaid-svg-fs4rzLgsc2MfG5ur .error-text{fill:#552222;stroke:#552222;}#mermaid-svg-fs4rzLgsc2MfG5ur .edge-thickness-normal{stroke-width:1px;}#mermaid-svg-fs4rzLgsc2MfG5ur .edge-thickness-thick{stroke-width:3.5px;}#mermaid-svg-fs4rzLgsc2MfG5ur .edge-pattern-solid{stroke-dasharray:0;}#mermaid-svg-fs4rzLgsc2MfG5ur .edge-thickness-invisible{stroke-width:0;fill:none;}#mermaid-svg-fs4rzLgsc2MfG5ur .edge-pattern-dashed{stroke-dasharray:3;}#mermaid-svg-fs4rzLgsc2MfG5ur .edge-pattern-dotted{stroke-dasharray:2;}#mermaid-svg-fs4rzLgsc2MfG5ur .marker{fill:#333333;stroke:#333333;}#mermaid-svg-fs4rzLgsc2MfG5ur .marker.cross{stroke:#333333;}#mermaid-svg-fs4rzLgsc2MfG5ur svg{font-family:"trebuchet ms",verdana,arial,sans-serif;font-size:16px;}#mermaid-svg-fs4rzLgsc2MfG5ur p{margin:0;}#mermaid-svg-fs4rzLgsc2MfG5ur .label{font-family:"trebuchet ms",verdana,arial,sans-serif;color:#333;}#mermaid-svg-fs4rzLgsc2MfG5ur .cluster-label text{fill:#333;}#mermaid-svg-fs4rzLgsc2MfG5ur .cluster-label span{color:#333;}#mermaid-svg-fs4rzLgsc2MfG5ur .cluster-label span p{background-color:transparent;}#mermaid-svg-fs4rzLgsc2MfG5ur .label text,#mermaid-svg-fs4rzLgsc2MfG5ur span{fill:#333;color:#333;}#mermaid-svg-fs4rzLgsc2MfG5ur .node rect,#mermaid-svg-fs4rzLgsc2MfG5ur .node circle,#mermaid-svg-fs4rzLgsc2MfG5ur .node ellipse,#mermaid-svg-fs4rzLgsc2MfG5ur .node polygon,#mermaid-svg-fs4rzLgsc2MfG5ur .node path{fill:#ECECFF;stroke:#9370DB;stroke-width:1px;}#mermaid-svg-fs4rzLgsc2MfG5ur .rough-node .label text,#mermaid-svg-fs4rzLgsc2MfG5ur .node .label text,#mermaid-svg-fs4rzLgsc2MfG5ur .image-shape .label,#mermaid-svg-fs4rzLgsc2MfG5ur .icon-shape .label{text-anchor:middle;}#mermaid-svg-fs4rzLgsc2MfG5ur .node .katex path{fill:#000;stroke:#000;stroke-width:1px;}#mermaid-svg-fs4rzLgsc2MfG5ur .rough-node .label,#mermaid-svg-fs4rzLgsc2MfG5ur .node .label,#mermaid-svg-fs4rzLgsc2MfG5ur .image-shape .label,#mermaid-svg-fs4rzLgsc2MfG5ur .icon-shape .label{text-align:center;}#mermaid-svg-fs4rzLgsc2MfG5ur .node.clickable{cursor:pointer;}#mermaid-svg-fs4rzLgsc2MfG5ur .root .anchor path{fill:#333333!important;stroke-width:0;stroke:#333333;}#mermaid-svg-fs4rzLgsc2MfG5ur .arrowheadPath{fill:#333333;}#mermaid-svg-fs4rzLgsc2MfG5ur .edgePath .path{stroke:#333333;stroke-width:2.0px;}#mermaid-svg-fs4rzLgsc2MfG5ur .flowchart-link{stroke:#333333;fill:none;}#mermaid-svg-fs4rzLgsc2MfG5ur .edgeLabel{background-color:rgba(232,232,232, 0.8);text-align:center;}#mermaid-svg-fs4rzLgsc2MfG5ur .edgeLabel p{background-color:rgba(232,232,232, 0.8);}#mermaid-svg-fs4rzLgsc2MfG5ur .edgeLabel rect{opacity:0.5;background-color:rgba(232,232,232, 0.8);fill:rgba(232,232,232, 0.8);}#mermaid-svg-fs4rzLgsc2MfG5ur .labelBkg{background-color:rgba(232, 232, 232, 0.5);}#mermaid-svg-fs4rzLgsc2MfG5ur .cluster rect{fill:#ffffde;stroke:#aaaa33;stroke-width:1px;}#mermaid-svg-fs4rzLgsc2MfG5ur .cluster text{fill:#333;}#mermaid-svg-fs4rzLgsc2MfG5ur .cluster span{color:#333;}#mermaid-svg-fs4rzLgsc2MfG5ur div.mermaidTooltip{position:absolute;text-align:center;max-width:200px;padding:2px;font-family:"trebuchet ms",verdana,arial,sans-serif;font-size:12px;background:hsl(80, 100%, 96.2745098039%);border:1px solid #aaaa33;border-radius:2px;pointer-events:none;z-index:100;}#mermaid-svg-fs4rzLgsc2MfG5ur .flowchartTitleText{text-anchor:middle;font-size:18px;fill:#333;}#mermaid-svg-fs4rzLgsc2MfG5ur rect.text{fill:none;stroke-width:0;}#mermaid-svg-fs4rzLgsc2MfG5ur .icon-shape,#mermaid-svg-fs4rzLgsc2MfG5ur .image-shape{background-color:rgba(232,232,232, 0.8);text-align:center;}#mermaid-svg-fs4rzLgsc2MfG5ur .icon-shape p,#mermaid-svg-fs4rzLgsc2MfG5ur .image-shape p{background-color:rgba(232,232,232, 0.8);padding:2px;}#mermaid-svg-fs4rzLgsc2MfG5ur .icon-shape .label rect,#mermaid-svg-fs4rzLgsc2MfG5ur .image-shape .label rect{opacity:0.5;background-color:rgba(232,232,232, 0.8);fill:rgba(232,232,232, 0.8);}#mermaid-svg-fs4rzLgsc2MfG5ur .label-icon{display:inline-block;height:1em;overflow:visible;vertical-align:-0.125em;}#mermaid-svg-fs4rzLgsc2MfG5ur .node .label-icon path{fill:currentColor;stroke:revert;stroke-width:revert;}#mermaid-svg-fs4rzLgsc2MfG5ur :root{--mermaid-font-family:"trebuchet ms",verdana,arial,sans-serif;} 一次请求的 prompt_tokens
系统提示词

角色设定 + 约束 + 格式要求
对话历史

轮数越多,线性增长
检索片段

RAG 场景下往往是最大头
用户输入

通常是最小的一块
成本 = Σ tokens × 单价
杠杆一:max_tokens 设上限

管住输出侧
杠杆二:历史按 token 裁剪

不按条数(第 9 篇)
杠杆三:系统提示短且稳定

同时利好缓存命中
杠杆四:按任务分流模型

简单任务走低价档位

(图 6:成本构成与四个优化杠杆------先看清谁在吃 token,再决定优化谁)

关于"中文和 token 的换算",我不给你一个固定数字,而是给你一个标定方法 ------因为不同模型的 tokenizer 不同,业务语料的差异(数字、代码、英文缩写比例)也会显著影响结果 ,照抄网上的"1 汉字 = 1.5 token"很容易算出离谱的预算。方法很简单:拿一条真实的、有代表性的请求打一次,从响应的 usage 里读出 prompt_tokens,除以你这次实际输入的汉字数,就得到了你自己业务的经验系数。 这个数会比任何别人的结论都准。

我在这四件事里最想强调的是"用量埋点",因为它是唯一无法事后补救的。 你可以在任何一天加上超时、加重试、加限流,但你无法回溯补齐过去三个月的 token 用量 。而"能不能按业务线分摊成本"决定了你和业务方沟通时的位置 :有埋点,你能说出"这个功能一个月花了多少、占总量多少、值不值";没有埋点,你只有一个总数,对方问"能不能省一点"时,你只能回答"我们尽量"。

🖼️ 配图说明(白板手绘信息图 | 建议文件名:b2_cost_flow.png)

画面内容:采用参考图 12 的手绘马克笔白板版式,表达"账单为什么总比预期高"。画面左上角是一个手写的思考气泡,泡内写「用户只问了 20 个字」,气泡下方引出一条粗马克笔箭头,指向画面中央的面板。面板是一个用马克笔手绘的账单窗口,顶部三个页签「系统提示词」「对话历史」「检索片段」,页签下方是四行手写条目,每行左侧一个小图标、右侧一行说明:「系统提示词 / 角色设定 + 约束 + 格式要求」「对话历史 / 轮数越多,线性增长」「检索片段 / RAG 场景下往往是最大头」「用户输入 / 通常是最小的一块」。面板下方手写一行公式「成本 = Σ tokens × 单价」。画面右侧是一列四行要点条,每行一个手绘图标加一行字:①「杠杆一 / max_tokens 设上限,管住输出侧」②「杠杆二 / 历史按 token 裁剪,不按条数」③「杠杆三 / 系统提示短且稳定,同时利好缓存命中」④「杠杆四 / 按任务分流模型,简单任务走低价档位」。画面底部再补一张手绘小票,票面写「一份可预测的账单」并画一个向上的箭头与一个问号,暗示"能分摊才能谈优化"。

画面文字清单(生成后可直接排版覆盖,若生图中文有笔画错误就照此重打):用户只问了 20 个字、系统提示词、对话历史、检索片段、系统提示词 / 角色设定 + 约束 + 格式要求、对话历史 / 轮数越多,线性增长、检索片段 / RAG 场景下往往是最大头、用户输入 / 通常是最小的一块、成本 = Σ tokens × 单价、杠杆一 / max_tokens 设上限,管住输出侧、杠杆二 / 历史按 token 裁剪,不按条数、杠杆三 / 系统提示短且稳定,同时利好缓存命中、杠杆四 / 按任务分流模型,简单任务走低价档位、一份可预测的账单

GPT-image-2 提示词(英文,可直接复制):

text 复制代码
Hand-drawn marker whiteboard infographic explaining why an AI bill is larger than expected, a hand-sketched thought bubble at the top left with a Chinese caption, a thick marker arrow pointing to a hand-drawn billing window panel in the center with three tabs and four itemized rows each carrying a small icon, a handwritten formula line under the panel, a right-hand column of four icon-and-text takeaway lines, a small hand-drawn receipt sketch at the bottom with an upward arrow and a question mark, hand-drawn marker whiteboard illustration on an off-white paper background, thick wobbly blue and yellow marker strokes with scribble highlights, handwritten Chinese headings with a brush underline, a UI panel mockup with four tabs and small chips, a right-hand column of four icon-and-text takeaway lines, casual doodle arrows, a small receipt sketch at the bottom, all Chinese labels rendered sharply in a clean bold sans-serif typeface with uniform label size and correct strokes, no watermark, no signature

6️⃣ 适用边界与风险提示

6.1 ⚠️ 适用场景

本文这套方案(云端 API + Spring Boot 接入)适合下面这些情况:

  • 企业内部辅助工具 :内部知识问答、文档摘要、工单初分类、会议纪要整理------出错有人兜底、不影响资金与安全。
  • 人机协作而非自动执行 :模型产出建议,由人确认后再落地。这是我认为 2026 年绝大多数 Java 团队最该走的形态。
  • 数据可脱敏:输入内容能通过规则或人工方式去掉敏感字段后再出网。
  • 对延迟容忍在秒级:用户能接受"等两三秒"。

6.2 ⚠️ 不适用场景

下面这些情况,请直接换方案,不要在本文的方案上加补丁:

你的需求 为什么本文方案不行 该去哪
数据绝不允许出内网 云端 API 的本质就是把数据发出去了 第 3 篇:Ollama 本地模型
需要模型自主多步操作(查库→改状态→发通知) 这不是"接入",是 Agent 工程,涉及权限、幂等、可观测、高可用 系列一全 12 篇
毫秒级延迟的关键链路 模型推理的固有延迟就是秒级 不要把模型放进实时风控这类链路
强一致性的判定(金额计算、审批结论) 模型输出是概率性的,同一输入两次结果可能不同 模型只做"建议",裁决仍由规则引擎与人工完成
要求"回答 100% 准确"的场景 幻觉是当前模型的固有特性,不是配置问题 改为"检索+引用",让人核对原文(第 4 篇)

这张表里我想多说一句第四行,因为它是我见过最贵的误解。 有团队把模型的分类结果直接当作业务判定写进了审批流,结果在一次批量任务中,同一批数据被分到了两个类别(因为模型在不同批次里判断不一致),下游按类别做了资金操作。 问题不在模型,在于架构上把"概率性组件"放在了"需要确定性"的位置。 正确做法是把模型输出当作一个"待确认的建议":要么有人复核,要么有规则兜底,要么允许它只是"提示"而不产生副作用。

6.3 ⚠️ 版本与兼容性注意

四条容易踩的:

  1. 配置键名变化(.options 消失)不会报错,只会静默失效。 这是升级中最贵的一类问题,防法是启动时打印生效配置(4.3)。
  2. spring-ai-core 已拆分为多个模块,只引 starter,不引中间模块 ;出现 NoSuchMethodError 时第一个动作是 mvn dependency:tree | grep spring-ai。
  3. 2.0 更换了 Jackson 大版本,不要在业务代码里 new ObjectMapper() ------你 new 出来的那个实例不受框架配置(如 Java Time、未知字段策略)影响,序列化行为会和框架不一致,表现为"框架内部正常、你自己的转换偶尔炸"。
  4. 安全性:请确认你使用的版本已包含近期的安全修复 (2.0.x 维护分支上有若干 CVE 修复)。这条不是"建议",是接入前的准入检查------AI 相关依赖是近两年攻击面增长最快的部分之一。

6.4 ⚠️ 生产建议

按重要性排序,我给你七条,前三条是"上线前必须有":

  1. 密钥管理 :按环境、按子系统隔离 Key,不要一个 Key 跑所有环境(否则你无法定位"是谁在异常调用",也无法单独吊销一个环境的凭据)。
  2. 额度与账单告警 :在控制台设消费限额或用量告警。这是防止"一夜烧掉一个月预算"的唯一有效手段 ------因为这类故障在你自己的系统里往往不表现为错误。
  3. 出口网络白名单 :只放行供应商域名。好处不只是安全,还有可观测 ------任何绕过白名单的调用都会失败,你能立刻发现"某个模块在用另一个地址调模型"这种暗流量。
  4. 限流与降级 :见 5.2。降级要有明确的话术,不要给用户看一个 500。
  5. 审计日志 :谁在什么时间问了什么(元数据级别)。政企场景下这是硬要求,事后追责与合规检查都靠它。
  6. 内容安全 :输入与输出双向审核。输入侧防注入,输出侧防不当内容 ------注意输出侧审核是很多团队会漏的一环,模型完全可能因为检索到的文档内容而生成不合规表述。
  7. 数据合规 :确认你的数据类型是否属于需要评估的范围(个人信息、行业敏感数据),必要时咨询法务,不要靠工程师自己判断 。这一点我放在最后但优先级不低------它属于"做错了无法用技术手段挽回"的那一类。

6.5 六个高频问题

Q1:curl 明明能通,为什么 Java 里报 401?

这是被问得最多的问题,答案一定在"第二层",按出现概率排:

① 环境变量没有注入到应用进程。 你是在 shell 里 export 的,但应用是从 IDE 启动的------IDE 不继承你那个终端的环境变量。 在 IDE 运行配置里补上,或者用 .env + spring.config.import。

② Key 里混进了不可见字符。 从控制台复制时很可能带上首尾空格或换行。排查方法很直接:打印 Key 的长度和首尾 4 位 (永远不要打印完整 Key)。

③ 改的是 application-local.yml,但当前 profile 不是 local。 同理:改了配置但没重启(Spring 的配置不会热加载)。

④ Key 被吊销了但配置没更新。这种情况在做过一次轮换、但只更新了一部分实例时非常典型。

Q2:model not found 为什么会出现?模型名我明明是对的。

四个原因,前两个是"看起来对"的经典陷阱:

① 兼容模式与原生模式的模型名不完全一致。 同一个模型在两套接口下可能有不同的标识,你从一篇讲原生 SDK 的文章里抄来的名字,在兼容端点下可能就是错的。

② 大小写与分隔符 (qwen-plus / qwen_plus / Qwen-Plus)。大部分端点区分大小写。

③ 该档位未开通或不在当前工作空间的可用列表里。 表现是 400 而不是 401,很容易误导你以为是格式问题。

④ 用了带快照后缀的名字但那个快照已下线。 用 curl 快速把候选名二分一遍,比在 Java 里试快得多。

Q3:两条路线能在同一个项目里并存吗?

能,但必须显式隔离,否则会出现"两个 ChatModel Bean 抢注入"------表现是启动时报"期望 1 个 Bean 但发现 2 个",或者更糟:启动成功但注入了错误的那个。

java 复制代码
@Configuration
public class AiConfig {

    // 主干:兼容模式,作为默认
    @Bean
    @Primary
    ChatClient defaultChatClient(ChatClient.Builder builder) {
        return builder.defaultSystem("你是企业技术支持助手。").build();
    }

    // 旁路:原生 SDK,只给需要平台独有能力的少数接口用
    @Bean("nativeToolClient")
    ChatClient nativeToolClient(DashScopeChatModel model) {
        return ChatClient.create(model);
    }
}

关键点是 @Primary 与显式 Bean 名。 没有 @Primary,自动装配会在多个候选里随机挑一个(按名称排序),行为不可预期 ;用 @Qualifier("nativeToolClient") 在需要的地方精确注入。这样做的纪律是:原生那条路只能被显式点名使用,不允许出现在任何自动注入的位置 ------否则某天你加了一个新接口,它悄悄用上了原生 SDK,而你以为是兼容模式。

Q4:流式接口前端没有"一个字一个字"出来,最后也没有结束信号。

按这四步查:

① produces 有没有声明 text/event-stream (漏了就会变成一次性返回)。

② 中间是否有网关在做缓冲 (Nginx 的 proxy_buffering 默认是开的)。这是最常见的一种"本地好好的、上线就不流了"。

③ 前端是否用了普通 fetch 而不是流式读取 。用浏览器 Network 面板看响应是否分块到达。

④ 结束信号 :流式响应通常以一个特殊结束标记收尾,前端要处理"正常结束"和"连接断开"两种情况,否则会出现"打字打到一半停了但界面没有报错"。

Q5:响应慢,到底慢在哪?

先把两个数分开测,测法不同、优化方向也不同:

  • 首 token 延迟 (从发出到收到第一个字)偏大 → 看提示词长度 (系统提示 + 历史 + 检索片段可能上万 token,输入越长首字越慢)、看模型档位 (高档位更慢)、看是否跨地域调用。
  • 总耗时偏大但首 token 很快 → 看输出长度与是否流式 。回答特别长的任务,用户体感完全取决于有没有开流式 ------同样 8 秒的完整生成,开了流式用户 1 秒就看到字了,不开就是干等 8 秒。

如果两个数都正常但用户报"慢",那大概率不是模型的问题 ,而是排队、连接池、或中间网关,回到 5.2 的三件套去查。

Q6:429 到底是限流还是欠费?

两者的表现非常像,但处置完全不同,必须分清:

  • 限流(QPS/并发超限) :说明你的调用太密。处置是退避重试 + 调低并发上限 + 加本地限流。
  • 额度耗尽/欠费 :说明你的账号没钱了,重试永远无效,而且会一直失败。 处置是充值或提额,同时它应该触发一个高优先级告警------因为这类故障是 100% 不可自愈的。

区分方法:看响应体里的错误类型字段与错误信息文案 (不同平台字段名不同,以官方错误码文档为准),同时在自己的监控上给这两个原因打不同的标签 。把它们混在一个 429 计数里,后果是"欠费"这种必须立刻人工介入的故障,被当成了"偶发限流"而没人管。
#mermaid-svg-P9Jzjk8QCDjwpYDQ{font-family:"trebuchet ms",verdana,arial,sans-serif;font-size:16px;fill:#333;}@keyframes edge-animation-frame{from{stroke-dashoffset:0;}}@keyframes dash{to{stroke-dashoffset:0;}}#mermaid-svg-P9Jzjk8QCDjwpYDQ .edge-animation-slow{stroke-dasharray:9,5!important;stroke-dashoffset:900;animation:dash 50s linear infinite;stroke-linecap:round;}#mermaid-svg-P9Jzjk8QCDjwpYDQ .edge-animation-fast{stroke-dasharray:9,5!important;stroke-dashoffset:900;animation:dash 20s linear infinite;stroke-linecap:round;}#mermaid-svg-P9Jzjk8QCDjwpYDQ .error-icon{fill:#552222;}#mermaid-svg-P9Jzjk8QCDjwpYDQ .error-text{fill:#552222;stroke:#552222;}#mermaid-svg-P9Jzjk8QCDjwpYDQ .edge-thickness-normal{stroke-width:1px;}#mermaid-svg-P9Jzjk8QCDjwpYDQ .edge-thickness-thick{stroke-width:3.5px;}#mermaid-svg-P9Jzjk8QCDjwpYDQ .edge-pattern-solid{stroke-dasharray:0;}#mermaid-svg-P9Jzjk8QCDjwpYDQ .edge-thickness-invisible{stroke-width:0;fill:none;}#mermaid-svg-P9Jzjk8QCDjwpYDQ .edge-pattern-dashed{stroke-dasharray:3;}#mermaid-svg-P9Jzjk8QCDjwpYDQ .edge-pattern-dotted{stroke-dasharray:2;}#mermaid-svg-P9Jzjk8QCDjwpYDQ .marker{fill:#333333;stroke:#333333;}#mermaid-svg-P9Jzjk8QCDjwpYDQ .marker.cross{stroke:#333333;}#mermaid-svg-P9Jzjk8QCDjwpYDQ svg{font-family:"trebuchet ms",verdana,arial,sans-serif;font-size:16px;}#mermaid-svg-P9Jzjk8QCDjwpYDQ p{margin:0;}#mermaid-svg-P9Jzjk8QCDjwpYDQ .label{font-family:"trebuchet ms",verdana,arial,sans-serif;color:#333;}#mermaid-svg-P9Jzjk8QCDjwpYDQ .cluster-label text{fill:#333;}#mermaid-svg-P9Jzjk8QCDjwpYDQ .cluster-label span{color:#333;}#mermaid-svg-P9Jzjk8QCDjwpYDQ .cluster-label span p{background-color:transparent;}#mermaid-svg-P9Jzjk8QCDjwpYDQ .label text,#mermaid-svg-P9Jzjk8QCDjwpYDQ span{fill:#333;color:#333;}#mermaid-svg-P9Jzjk8QCDjwpYDQ .node rect,#mermaid-svg-P9Jzjk8QCDjwpYDQ .node circle,#mermaid-svg-P9Jzjk8QCDjwpYDQ .node ellipse,#mermaid-svg-P9Jzjk8QCDjwpYDQ .node polygon,#mermaid-svg-P9Jzjk8QCDjwpYDQ .node path{fill:#ECECFF;stroke:#9370DB;stroke-width:1px;}#mermaid-svg-P9Jzjk8QCDjwpYDQ .rough-node .label text,#mermaid-svg-P9Jzjk8QCDjwpYDQ .node .label text,#mermaid-svg-P9Jzjk8QCDjwpYDQ .image-shape .label,#mermaid-svg-P9Jzjk8QCDjwpYDQ .icon-shape .label{text-anchor:middle;}#mermaid-svg-P9Jzjk8QCDjwpYDQ .node .katex path{fill:#000;stroke:#000;stroke-width:1px;}#mermaid-svg-P9Jzjk8QCDjwpYDQ .rough-node .label,#mermaid-svg-P9Jzjk8QCDjwpYDQ .node .label,#mermaid-svg-P9Jzjk8QCDjwpYDQ .image-shape .label,#mermaid-svg-P9Jzjk8QCDjwpYDQ .icon-shape .label{text-align:center;}#mermaid-svg-P9Jzjk8QCDjwpYDQ .node.clickable{cursor:pointer;}#mermaid-svg-P9Jzjk8QCDjwpYDQ .root .anchor path{fill:#333333!important;stroke-width:0;stroke:#333333;}#mermaid-svg-P9Jzjk8QCDjwpYDQ .arrowheadPath{fill:#333333;}#mermaid-svg-P9Jzjk8QCDjwpYDQ .edgePath .path{stroke:#333333;stroke-width:2.0px;}#mermaid-svg-P9Jzjk8QCDjwpYDQ .flowchart-link{stroke:#333333;fill:none;}#mermaid-svg-P9Jzjk8QCDjwpYDQ .edgeLabel{background-color:rgba(232,232,232, 0.8);text-align:center;}#mermaid-svg-P9Jzjk8QCDjwpYDQ .edgeLabel p{background-color:rgba(232,232,232, 0.8);}#mermaid-svg-P9Jzjk8QCDjwpYDQ .edgeLabel rect{opacity:0.5;background-color:rgba(232,232,232, 0.8);fill:rgba(232,232,232, 0.8);}#mermaid-svg-P9Jzjk8QCDjwpYDQ .labelBkg{background-color:rgba(232, 232, 232, 0.5);}#mermaid-svg-P9Jzjk8QCDjwpYDQ .cluster rect{fill:#ffffde;stroke:#aaaa33;stroke-width:1px;}#mermaid-svg-P9Jzjk8QCDjwpYDQ .cluster text{fill:#333;}#mermaid-svg-P9Jzjk8QCDjwpYDQ .cluster span{color:#333;}#mermaid-svg-P9Jzjk8QCDjwpYDQ div.mermaidTooltip{position:absolute;text-align:center;max-width:200px;padding:2px;font-family:"trebuchet ms",verdana,arial,sans-serif;font-size:12px;background:hsl(80, 100%, 96.2745098039%);border:1px solid #aaaa33;border-radius:2px;pointer-events:none;z-index:100;}#mermaid-svg-P9Jzjk8QCDjwpYDQ .flowchartTitleText{text-anchor:middle;font-size:18px;fill:#333;}#mermaid-svg-P9Jzjk8QCDjwpYDQ rect.text{fill:none;stroke-width:0;}#mermaid-svg-P9Jzjk8QCDjwpYDQ .icon-shape,#mermaid-svg-P9Jzjk8QCDjwpYDQ .image-shape{background-color:rgba(232,232,232, 0.8);text-align:center;}#mermaid-svg-P9Jzjk8QCDjwpYDQ .icon-shape p,#mermaid-svg-P9Jzjk8QCDjwpYDQ .image-shape p{background-color:rgba(232,232,232, 0.8);padding:2px;}#mermaid-svg-P9Jzjk8QCDjwpYDQ .icon-shape .label rect,#mermaid-svg-P9Jzjk8QCDjwpYDQ .image-shape .label rect{opacity:0.5;background-color:rgba(232,232,232, 0.8);fill:rgba(232,232,232, 0.8);}#mermaid-svg-P9Jzjk8QCDjwpYDQ .label-icon{display:inline-block;height:1em;overflow:visible;vertical-align:-0.125em;}#mermaid-svg-P9Jzjk8QCDjwpYDQ .node .label-icon path{fill:currentColor;stroke:revert;stroke-width:revert;}#mermaid-svg-P9Jzjk8QCDjwpYDQ :root{--mermaid-font-family:"trebuchet ms",verdana,arial,sans-serif;} 第一层
第二层
第三层
接口异常
先定位层
curl 直连供应商端点
Q1 401:环境变量 / 空格 / profile / 已吊销
Q2 400 model not found:命名不一致 / 未开通 / 快照下线
Q6 429:区分限流(可退避)与欠费(不可自愈,需告警)
打印实际生效的配置
Q4 流式:produces / 网关缓冲 / 前端读取 / 结束标记
Q5 慢:首 token 延迟 vs 总耗时,分开测
看堆栈与 Bean 装配
Q3 多模型 Bean 冲突:@Primary + 显式 Bean 名

(图 7:六个高频问题按"层"归类------每一层有各自的排查动作,不要跨层试)

🖼️ 配图说明(边界反例信息图 | 建议文件名:b2_boundary.png)

画面内容:采用参考图 10 的暗色实景+悬浮玻璃卡+红叉版式,表达"这套方案能做与不能做"。画面主体是夜晚的办公室实景,正中一枚发光青色节点写「一次普通的模型调用」。向右分出三条发光曲线,连到三张悬浮玻璃卡(可做):①「问答与摘要」「有检索、有人核对」②「文本改写与分类」「结果可人工复核」③「结构化抽取」「有 schema 校验兜底」。向左下方单独悬出一张被红叉划掉的大卡,红色虚线连接,卡面写「这些别放进来」,卡内三行小字各带一个红叉:「数据绝不允许出内网」「需要模型自主多步操作(查库→改状态→发通知)」「要求回答 100% 准确(幻觉是固有特性)」。红叉卡下方再补一行小字「毫秒级关键链路也不要放------模型推理的固有延迟就是秒级」。三张正卡底部各一枚绿色状态圆点,红叉卡整张压暗。整幅画面正卡与反例卡共同填满画幅。

画面文字清单(生成后可直接排版覆盖,若生图中文有笔画错误就照此重打):一次普通的模型调用、问答与摘要、有检索、有人核对、文本改写与分类、结果可人工复核、结构化抽取、有 schema 校验兜底、这些别放进来、数据绝不允许出内网、需要模型自主多步操作(查库→改状态→发通知)、要求回答 100% 准确(幻觉是固有特性)、毫秒级关键链路也不要放------模型推理的固有延迟就是秒级

GPT-image-2 提示词(英文,可直接复制):

text 复制代码
Dark cinematic glass-card infographic about capability boundaries, a night office scene with a glowing cyan node labeled in Chinese in the center, three luminous light-trails leading to three floating rounded glass cards with green status dots and two caption lines each, one large dimmed card marked with a bold red cross and linked by a red dashed line, the red-cross card holding three bullet lines each preceded by a small red cross, soft bokeh, blue-violet night color grade, dark cinematic real-scene composite of a dim office at night with a glowing server rack, a glowing cyan network node in the center, luminous light-trails leading to three floating rounded glass cards each with a capsule label, one separate card marked with a bold red cross and connected by a red dashed line, soft bokeh, blue-violet night color grade, all Chinese labels rendered sharply in a clean bold sans-serif typeface with uniform label size and correct strokes, no watermark, no signature

7️⃣ 总结

7.1 回到开头:第一天的半小时到底难在哪

现在回头看,那半小时的困难并不在代码------代码只有二十行。困难在于"你不知道自己站在哪一层"。 Key 在控制台,网络在防火墙,配置在 yml,抽象在框架,质量在提示词,四层里任何一层出问题,报错看起来都差不多,而网上的教程各自假设你在不同的层。

所以我把这篇文章的骨架定成了"先分层,再分路线":第 2 章的四层排错决策树是方法,第 3、4 章的两条路线是两种落地,第 5 章的四件事是把"能演示"变成"能上线",第 6 章是边界。

7.2 本篇的五个核心决策点

如果这篇只能记五句话,我记这五句:

  1. 新项目走路线 B(兼容模式 + 最新 Spring AI) ;路线 A 只在"存量项目 + 确实需要平台独有特性"时保留。判断标准是"你的需求清单里有没有原生能力",不是"哪家更国产"。
  2. 任何报错,先问"它在哪一层",并且不要跨层试。 curl 通了就永远不再怀疑 Key------这条纪律能省掉你一多半的无效排查。
  3. 模型名做成配置项,别硬编码。 换模型是 AI 应用里最高频的变更;PoC 用滚动别名、生产用快照名。
  4. 上线前必须有超时、重试、限流 ,其中两条 AI 特有的纪律是:429 必须退避(立即重试会加剧限流)、流式输出开始或工具已执行后绝不自动重试。
  5. 第一周就把用量埋点做上。 它是唯一无法事后补齐的东西,也是"能不能按业务线分摊成本"的前提。

7.3 一页纸速查

我要做什么 怎么做
验证凭据与网络 一条 curl 打 compatible-mode/v1/chat/completions,看 choices + usage
换供应商 只改 base-url + model 两个值(路线 B 的全部迁移成本)
确认配置真的生效 启动时打印模型名 / 超时 / 温度;键名写错不会报错,只会静默失效
依赖冲突自查 `mvn dependency:tree
结构化输出 .call().entity(Xxx.class),别用字符串拼 JSON 再手解析
流式输出 返回 Flux<String> + produces = text/event-stream,并检查网关缓冲
两个 ChatModel 并存 @Primary + 显式 Bean 名 + @Qualifier 精确注入
判断"慢"的归属 分开测首 token 延迟与总耗时,两个数指向不同优化方向
区分 429 的两种原因 限流(退避可解)vs 欠费(不可自愈,必须告警)
紧急处置密钥泄露 先吊销、再换新 Key,然后才改代码;git 历史无法真正抹除

7.4 系列地图

# 篇目 定位
1 Spring AI 是什么?Java 工程师的 AI 开发第一站 认知与选型
2 Spring Boot + 通义千问:从拿 Key 到第一个对话接口跑通 本篇:跑得起来
3 Spring AI + Ollama 本地模型:数据不出内网的合规方案 合规底线
4 用 Spring AI 搭 RAG 问答:把公司文档喂给大模型 知识注入
5 Spring AI 函数调用:让 Java 方法变成 Agent 的工具 能力扩展
6 LangChain4j vs Spring AI:Java AI 框架怎么选 横向对比
7 用 Spring AI 做文本分类/摘要:落地到后台工作流 业务价值
8 流式输出:让 AI 回答像打字机一样实时返回 体验与工程
9 多轮对话管理:Session/记忆在 Java 后端怎么存 状态管理
10 AI 接口限流与降级:高并发下保护你的模型额度 稳定性
11 Spring AI 可观测性:接入 Micrometer + Prometheus 可观测
12 一个完整案例:用 Spring AI 做工单智能分派 收官案例

下一篇换一个方向:如果"数据不允许出内网",上面这套云端方案整条作废。 第 3 篇会讲本地模型------不是"防个身"的演示,而是在国产化与合规要求下真正可交付的内网方案 :显存怎么算、模型档位怎么选、Java 侧怎么把它接到同一套代码上(这里会看到兼容模式的另一个价值:换成本地 Ollama,你的业务代码依然不用改)。

如果你已经跑通了第一个接口 ,欢迎在评论区说说你选的是哪条路线、以及卡在哪一层------尤其是那些"配置写了但静默没生效"的经历,这类信息对后来者最有用。


参考资料

相关推荐
SimonKing1 小时前
别再花钱调 OCR 接口了!这个国产开源工具,免费还能离线跑
java·后端·程序员
Nebula_g1 小时前
SpringBoot企业级实战项目—云禾外卖—SpringBoot常用注解解析
java·网络·数据库·spring boot·后端·spring·javase
const_qiu1 小时前
第三章 07:3.9 能力收束与统一入口
java·人工智能
const_qiu1 小时前
第三章 05:3.7 结构化输出与程序化消费
java·人工智能
企业数字化笔记2 小时前
租户停用后数据和任务怎么处理?冻结、恢复与保留策略的实现
android·java·spring boot
CarIise2 小时前
Spring MVC 请求参数绑定与常用注解
java·spring·mvc
晓得迷路了2 小时前
栗子前端技术周刊第 150 期 - Stack Overflow 开发者调查结果、TanStack Start 1.0...
javascript·css·后端
vx_Biye_Design2 小时前
PHP中医养生馆预约系统60841-计算机课程设计、毕业设计
vue.js·spring boot·后端·mysql·php·课程设计·express
IT_陈寒2 小时前
Vite性能优化完全指南
前端·人工智能·后端