简介:先用一个 init skill 把工程地基一次起好,再写 Agent 的前两块核心能力:对接大模型的 Provider 和负责调度的 ReAct 循环
先起地基,再写业务
简介:日志、监控、HTTP、API 规范、代码规范、安全检查一次到位,再进入五大能力开发
-
开发顺序
- ① 先起工程地基:日志 · 监控 · HTTP · API 规范 · 代码规范 · 安全检查
- ② 再写五大能力:Provider · ReAct · Memory · Tool · Web Service
- 地基稳,后面每个能力开发才快、才稳
- 这一步不直接产出业务,却决定了整个项目的工程质量
- 地基不稳,越往后越乱
-
工程地基包含六块
| 模块 | 技术选型 |
|---|---|
| 结构化日志 | SLF4J + Logback,生产环境 JSON 输出 + traceId |
| 监控 | Actuator + Micrometer + Prometheus,暴露 health / metrics |
| HTTP Server | Spring MVC + JDK 21 虚拟线程,单机扛高并发 |
| API 规范 | springdoc OpenAPI + 统一响应体 + 全局异常 / 错误码 |
| 代码规范 | Google 格式(Spotless)+ 阿里编码规约(P3C)+ Checkstyle |
| 安全检查 | SpotBugs + Find Security Bugs + PMD + OWASP 依赖扫描 |
- 关键点
- 六块都和业务无关、每个项目都要,正适合用一个 skill 一次装好
用项目自己的 init skill 固化地基
简介:通用 skill 起完还得大改,自己的 skill 负责编排,内部用业界标准工具落地
- 为什么不用现成的通用 skill
| 维度 | 现成的通用 skill | 项目自己的 init skill |
|---|---|---|
| 模块结构 | 不懂项目的 9 模块结构 | 9 模块骨架一次就位 |
| 关键决策 | 不懂 constitution 里的关键决策 | 与 constitution 对齐(如自实现 ReAct) |
| 命名 / 目录 | 对不上,起完还要返工 | 按团队约定来 |
| 复用 | 每次都要改 | 内部用标准工具落地,团队 / 社区可长期复用 |
-
Skill 的最佳用法:固化「通用、可重复」的流程
-
init skill 做什么
- 一个 SKILL.md,把 10 步初始化压成六个阶段,照着一步步跑

-
规范两层(互补不冲突)
- 格式:Google(Spotless + google-java-format)
- 规约:阿里 Java 开发手册(P3C)
- 两者冲突时,以可自动修复的 google-java-format 为准
-
安全四件套(接入
mvn verify)- SpotBugs + Find Security Bugs(OWASP 规则)
- PMD + 阿里 P3C 规则
- OWASP Dependency-Check:扫描依赖里的 CVE
-
分工
- skill 负责「装」,constitution 负责「钉死」,CI 负责「强制」
- 地基起好之后,再用 Spec-Kit 写业务
-
落地三步
| 步骤 | 内容 |
|---|---|
| 起 9 模块骨架 | Maven 多模块 + 父 pom 统一管理版本 + boot 启动模块 |
| 用 skill 装地基 | 日志 / 监控 / HTTP / API / 规范(Google + 阿里)/ 安全四件套 |
mvn verify 全绿 |
spotless / P3C / SpotBugs / depcheck 全部通过,起服务跑通 |
- 验收
/actuator/health与/prometheus正常/swagger-ui能打开- 规范与安全检查全部生效
Provider:Agent 和大模型之间的前台
简介:Provider 按 Profile 挑对模型、发起一次调用、把结果原样返回,上层换模型只改配置
-
核心概念
- Provider 就是 Agent 和大模型之间的一个前台
- Agent = LLM + Tools + Memory + Loop + Environment,Provider 把 LLM 这个大脑接进系统
- ReAct 循环每转一圈,都要通过 Provider 调一次大模型
- 技术栈:JDK 21 + Spring Boot 3.x + Spring AI Alibaba
-
工作方式
- 上层传入 Profile(用哪个 provider、哪个 model)+ Prompt(发给模型的内容)
- Provider 按 Profile 选出对应模型,调用,把结果原样返回
- 好处:以后换模型只改配置,ReAct 那边一行都不用动

-
ChatModel:Spring AI 里代表"一个具体大模型"的对象,一个 provider 对应一个 -
工具:Provider 只翻译,不执行
- Function Calling:发请求时顺带告诉模型有哪些工具、每个工具要传什么参数,这份说明叫 schema
- 模型可能回复"我想调
http_get,参数是这些",模型只是说它想调,并不会真去调 - Provider 把调用请求原样交回上层循环,真正执行的是 ToolExecutor
Provider 动手前先想清楚的事
简介:职责划窄、协议转换不重复造、提前绕开三个坑,Provider 的架构就定了
- 职责划窄
| Provider 负责 | Provider 不管 |
|---|---|
| 挑对哪家模型 | 循环怎么转 → ReActLoop |
| 组装并发起一次 LLM 调用 | 工具怎么执行 → ToolExecutor |
| 把工具翻译成各家能懂的格式 | 上下文怎么拼 → PromptBuilder |
| 记录这次调用的用量 / 审计 | 记忆怎么存 → MemoryService |
-
关键点
- 边界不划清,Provider 会越写越胖,最后和 ReActLoop 缠在一起分不开
- OpenAI、Anthropic、Gemini 的工具格式各不相同,转换交给 Spring AI Alibaba,只写薄薄一层
ProviderService
-
三个坑
| 坑 | 问题 | 解法 |
|---|---|---|
| 多个 provider 分不清 | DeepSeek、Kimi 在容器里都是 ChatModel 类型,按类型扫描分不清谁是谁 |
自己维护 provider 名 → ChatModel 的映射表 |
| Spring AI 自动执行工具 | 和自写的 ReActLoop + ToolExecutor 叠加,工具被调两次,还绕过沙箱检查 | 关掉自动执行,只用它的协议转换和 schema 生成 |
| 模型依赖未必存在 | 每家模型是独立 starter,某些 milestone 版本的 BOM 里未必包含 | 动手前 mvn dependency:tree 确认能下载、能解析 |
- 结论
- 一个薄薄的抽象层,只管一次调用,靠一张表显式选模型,关掉自动执行,接哪家提前验证依赖
Provider 代码实现
简介:核心一个 ProviderService,对外只露 chat(sessionId, Profile, Prompt),外加工具适配器和审计

- 第零步:Profile 解析加载(Provider 是第一个消费 Profile 的模块,所以由它交付)
| 类 | 职责 |
|---|---|
Profile |
记录类,字段一次建全:name、description、identity、provider(name / model / temperature)、tools、skills、mcp_servers、channels、notify_channels、schedules、bootstrap、settings |
ProfileLoader |
启动时扫 .agent/profiles/ 下所有 YAML,用 SnakeYAML 解析并校验;坏的 Profile 记错误日志,不阻断启动 |
ProfileRegistry |
内存索引 Map<String, Profile>,按 name 查找 |
- 第一步:配置分两层
| 层 | 位置 | 管什么 |
|---|---|---|
| 全局层 | application.yaml |
接了哪些 provider、凭证从哪个环境变量读,解决"连不连得上" |
| Profile 层 | 每个 Agent 自己的 YAML | 用哪个 provider、哪个 model、什么温度,解决"这个 Agent 怎么用" |
# application.yaml —— 全局层:声明有哪些 provider、凭证从哪来
agent:
providers:
- name: deepseek
api-key: ${DEEPSEEK_API_KEY}
- name: kimi
api-key: ${KIMI_API_KEY}
# .agent/profiles/ops-agent.yaml —— Profile 层:这个 Agent 具体怎么用
provider:
name: deepseek # 必须能在全局层的 providers 列表里找到同名项
model: deepseek-chat # 用哪个模型,Profile 自己定
temperature: 0.7# application.yaml ------ 全局层:声明有哪些 provider、凭证从哪来
# application.yaml —— 全局层:声明有哪些 provider、凭证从哪来
agent:
providers:
- name: deepseek
api-key: ${DEEPSEEK_API_KEY}
- name: kimi
api-key: ${KIMI_API_KEY}
# .agent/profiles/ops-agent.yaml —— Profile 层:这个 Agent 具体怎么用
provider:
name: deepseek # 必须能在全局层的 providers 列表里找到同名项
model: deepseek-chat # 用哪个模型,Profile 自己定
temperature: 0.7agent:
# application.yaml —— 全局层:声明有哪些 provider、凭证从哪来
agent:
providers:
- name: deepseek
api-key: ${DEEPSEEK_API_KEY}
- name: kimi
api-key: ${KIMI_API_KEY}
# .agent/profiles/ops-agent.yaml —— Profile 层:这个 Agent 具体怎么用
provider:
name: deepseek # 必须能在全局层的 providers 列表里找到同名项
model: deepseek-chat # 用哪个模型,Profile 自己定
temperature: 0.7 providers:
# application.yaml —— 全局层:声明有哪些 provider、凭证从哪来
agent:
providers:
- name: deepseek
api-key: ${DEEPSEEK_API_KEY}
- name: kimi
api-key: ${KIMI_API_KEY}
# .agent/profiles/ops-agent.yaml —— Profile 层:这个 Agent 具体怎么用
provider:
name: deepseek # 必须能在全局层的 providers 列表里找到同名项
model: deepseek-chat # 用哪个模型,Profile 自己定
temperature: 0.7 - name: deepseek
# application.yaml —— 全局层:声明有哪些 provider、凭证从哪来
agent:
providers:
- name: deepseek
api-key: ${DEEPSEEK_API_KEY}
- name: kimi
api-key: ${KIMI_API_KEY}
# .agent/profiles/ops-agent.yaml —— Profile 层:这个 Agent 具体怎么用
provider:
name: deepseek # 必须能在全局层的 providers 列表里找到同名项
model: deepseek-chat # 用哪个模型,Profile 自己定
temperature: 0.7 api-key: ${DEEPSEEK_API_KEY}
# application.yaml —— 全局层:声明有哪些 provider、凭证从哪来
agent:
providers:
- name: deepseek
api-key: ${DEEPSEEK_API_KEY}
- name: kimi
api-key: ${KIMI_API_KEY}
# .agent/profiles/ops-agent.yaml —— Profile 层:这个 Agent 具体怎么用
provider:
name: deepseek # 必须能在全局层的 providers 列表里找到同名项
model: deepseek-chat # 用哪个模型,Profile 自己定
temperature: 0.7 - name: kimi
# application.yaml —— 全局层:声明有哪些 provider、凭证从哪来
agent:
providers:
- name: deepseek
api-key: ${DEEPSEEK_API_KEY}
- name: kimi
api-key: ${KIMI_API_KEY}
# .agent/profiles/ops-agent.yaml —— Profile 层:这个 Agent 具体怎么用
provider:
name: deepseek # 必须能在全局层的 providers 列表里找到同名项
model: deepseek-chat # 用哪个模型,Profile 自己定
temperature: 0.7 api-key: ${KIMI_API_KEY}
# application.yaml —— 全局层:声明有哪些 provider、凭证从哪来
agent:
providers:
- name: deepseek
api-key: ${DEEPSEEK_API_KEY}
- name: kimi
api-key: ${KIMI_API_KEY}
# .agent/profiles/ops-agent.yaml —— Profile 层:这个 Agent 具体怎么用
provider:
name: deepseek # 必须能在全局层的 providers 列表里找到同名项
model: deepseek-chat # 用哪个模型,Profile 自己定
temperature: 0.7
# application.yaml —— 全局层:声明有哪些 provider、凭证从哪来
agent:
providers:
- name: deepseek
api-key: ${DEEPSEEK_API_KEY}
- name: kimi
api-key: ${KIMI_API_KEY}
# .agent/profiles/ops-agent.yaml —— Profile 层:这个 Agent 具体怎么用
provider:
name: deepseek # 必须能在全局层的 providers 列表里找到同名项
model: deepseek-chat # 用哪个模型,Profile 自己定
temperature: 0.7# .agent/profiles/ops-agent.yaml ------ Profile 层:这个 Agent 具体怎么用
# application.yaml —— 全局层:声明有哪些 provider、凭证从哪来
agent:
providers:
- name: deepseek
api-key: ${DEEPSEEK_API_KEY}
- name: kimi
api-key: ${KIMI_API_KEY}
# .agent/profiles/ops-agent.yaml —— Profile 层:这个 Agent 具体怎么用
provider:
name: deepseek # 必须能在全局层的 providers 列表里找到同名项
model: deepseek-chat # 用哪个模型,Profile 自己定
temperature: 0.7provider:
# application.yaml —— 全局层:声明有哪些 provider、凭证从哪来
agent:
providers:
- name: deepseek
api-key: ${DEEPSEEK_API_KEY}
- name: kimi
api-key: ${KIMI_API_KEY}
# .agent/profiles/ops-agent.yaml —— Profile 层:这个 Agent 具体怎么用
provider:
name: deepseek # 必须能在全局层的 providers 列表里找到同名项
model: deepseek-chat # 用哪个模型,Profile 自己定
temperature: 0.7 name: deepseek # 必须能在全局层的 providers 列表里找到同名项
# application.yaml —— 全局层:声明有哪些 provider、凭证从哪来
agent:
providers:
- name: deepseek
api-key: ${DEEPSEEK_API_KEY}
- name: kimi
api-key: ${KIMI_API_KEY}
# .agent/profiles/ops-agent.yaml —— Profile 层:这个 Agent 具体怎么用
provider:
name: deepseek # 必须能在全局层的 providers 列表里找到同名项
model: deepseek-chat # 用哪个模型,Profile 自己定
temperature: 0.7 model: deepseek-chat # 用哪个模型,Profile 自己定
# application.yaml —— 全局层:声明有哪些 provider、凭证从哪来
agent:
providers:
- name: deepseek
api-key: ${DEEPSEEK_API_KEY}
- name: kimi
api-key: ${KIMI_API_KEY}
# .agent/profiles/ops-agent.yaml —— Profile 层:这个 Agent 具体怎么用
provider:
name: deepseek # 必须能在全局层的 providers 列表里找到同名项
model: deepseek-chat # 用哪个模型,Profile 自己定
temperature: 0.7 temperature: 0.7
-
注意点
- Profile 引用的 provider.name 在全局层找不到同名项,必须直接报错,不能悄悄用错或留空跑过去
${DEEPSEEK_API_KEY}运行时从环境变量取,代码和配置文件里都不出现真实 key
-
第二步:建映射表
- 启动时按 providers 列表逐条创建
ChatModel,存进Map<String, ChatModel> - 用
@Qualifier或手动 put 都行,谁对谁必须写死、看得见
- 启动时按 providers 列表逐条创建
-
第三步:写 chat 方法
java
#public Response chat(String sessionId, Profile profile, Prompt prompt) {
ChatModel model = providerMap.get(profile.getProvider()); // 按名字取模型
if (model == null) {
throw new ProviderNotFoundException(profile.getProvider());
}
var tools = adapter.toSpringAiTools(prompt.getAvailableTools()); // 只翻译,不执行
long startedAt = System.currentTimeMillis();
try {
Response resp = model.call(request(prompt, tools, /* autoExecuteTools */ false)); // 关掉自动执行
audit.record(sessionId, profile.getProvider(), profile.getModel(), resp.usage(),
true, null, System.currentTimeMillis() - startedAt);
return resp; // 响应里可能带工具调用请求,原样交回上层
} catch (RuntimeException e) {
audit.record(sessionId, profile.getProvider(), profile.getModel(), null,
false, e.getMessage(), System.currentTimeMillis() - startedAt);
throw e; // 失败也留痕,再把错误抛给上层
}
}public Response chat(String sessionId, Profile profile, Prompt prompt) {`
`#public Response chat(String sessionId, Profile profile, Prompt prompt) {
ChatModel model = providerMap.get(profile.getProvider()); // 按名字取模型
if (model == null) {
throw new ProviderNotFoundException(profile.getProvider());
}
var tools = adapter.toSpringAiTools(prompt.getAvailableTools()); // 只翻译,不执行
long startedAt = System.currentTimeMillis();
try {
Response resp = model.call(request(prompt, tools, /* autoExecuteTools */ false)); // 关掉自动执行
audit.record(sessionId, profile.getProvider(), profile.getModel(), resp.usage(),
true, null, System.currentTimeMillis() - startedAt);
return resp; // 响应里可能带工具调用请求,原样交回上层
} catch (RuntimeException e) {
audit.record(sessionId, profile.getProvider(), profile.getModel(), null,
false, e.getMessage(), System.currentTimeMillis() - startedAt);
throw e; // 失败也留痕,再把错误抛给上层
}
} ChatModel model = providerMap.get(profile.getProvider()); // 按名字取模型`
`#public Response chat(String sessionId, Profile profile, Prompt prompt) {
ChatModel model = providerMap.get(profile.getProvider()); // 按名字取模型
if (model == null) {
throw new ProviderNotFoundException(profile.getProvider());
}
var tools = adapter.toSpringAiTools(prompt.getAvailableTools()); // 只翻译,不执行
long startedAt = System.currentTimeMillis();
try {
Response resp = model.call(request(prompt, tools, /* autoExecuteTools */ false)); // 关掉自动执行
audit.record(sessionId, profile.getProvider(), profile.getModel(), resp.usage(),
true, null, System.currentTimeMillis() - startedAt);
return resp; // 响应里可能带工具调用请求,原样交回上层
} catch (RuntimeException e) {
audit.record(sessionId, profile.getProvider(), profile.getModel(), null,
false, e.getMessage(), System.currentTimeMillis() - startedAt);
throw e; // 失败也留痕,再把错误抛给上层
}
} if (model == null) {`
`#public Response chat(String sessionId, Profile profile, Prompt prompt) {
ChatModel model = providerMap.get(profile.getProvider()); // 按名字取模型
if (model == null) {
throw new ProviderNotFoundException(profile.getProvider());
}
var tools = adapter.toSpringAiTools(prompt.getAvailableTools()); // 只翻译,不执行
long startedAt = System.currentTimeMillis();
try {
Response resp = model.call(request(prompt, tools, /* autoExecuteTools */ false)); // 关掉自动执行
audit.record(sessionId, profile.getProvider(), profile.getModel(), resp.usage(),
true, null, System.currentTimeMillis() - startedAt);
return resp; // 响应里可能带工具调用请求,原样交回上层
} catch (RuntimeException e) {
audit.record(sessionId, profile.getProvider(), profile.getModel(), null,
false, e.getMessage(), System.currentTimeMillis() - startedAt);
throw e; // 失败也留痕,再把错误抛给上层
}
} throw new ProviderNotFoundException(profile.getProvider());`
`#public Response chat(String sessionId, Profile profile, Prompt prompt) {
ChatModel model = providerMap.get(profile.getProvider()); // 按名字取模型
if (model == null) {
throw new ProviderNotFoundException(profile.getProvider());
}
var tools = adapter.toSpringAiTools(prompt.getAvailableTools()); // 只翻译,不执行
long startedAt = System.currentTimeMillis();
try {
Response resp = model.call(request(prompt, tools, /* autoExecuteTools */ false)); // 关掉自动执行
audit.record(sessionId, profile.getProvider(), profile.getModel(), resp.usage(),
true, null, System.currentTimeMillis() - startedAt);
return resp; // 响应里可能带工具调用请求,原样交回上层
} catch (RuntimeException e) {
audit.record(sessionId, profile.getProvider(), profile.getModel(), null,
false, e.getMessage(), System.currentTimeMillis() - startedAt);
throw e; // 失败也留痕,再把错误抛给上层
}
} }`
`#public Response chat(String sessionId, Profile profile, Prompt prompt) {
ChatModel model = providerMap.get(profile.getProvider()); // 按名字取模型
if (model == null) {
throw new ProviderNotFoundException(profile.getProvider());
}
var tools = adapter.toSpringAiTools(prompt.getAvailableTools()); // 只翻译,不执行
long startedAt = System.currentTimeMillis();
try {
Response resp = model.call(request(prompt, tools, /* autoExecuteTools */ false)); // 关掉自动执行
audit.record(sessionId, profile.getProvider(), profile.getModel(), resp.usage(),
true, null, System.currentTimeMillis() - startedAt);
return resp; // 响应里可能带工具调用请求,原样交回上层
} catch (RuntimeException e) {
audit.record(sessionId, profile.getProvider(), profile.getModel(), null,
false, e.getMessage(), System.currentTimeMillis() - startedAt);
throw e; // 失败也留痕,再把错误抛给上层
}
} var tools = adapter.toSpringAiTools(prompt.getAvailableTools()); // 只翻译,不执行`
`#public Response chat(String sessionId, Profile profile, Prompt prompt) {
ChatModel model = providerMap.get(profile.getProvider()); // 按名字取模型
if (model == null) {
throw new ProviderNotFoundException(profile.getProvider());
}
var tools = adapter.toSpringAiTools(prompt.getAvailableTools()); // 只翻译,不执行
long startedAt = System.currentTimeMillis();
try {
Response resp = model.call(request(prompt, tools, /* autoExecuteTools */ false)); // 关掉自动执行
audit.record(sessionId, profile.getProvider(), profile.getModel(), resp.usage(),
true, null, System.currentTimeMillis() - startedAt);
return resp; // 响应里可能带工具调用请求,原样交回上层
} catch (RuntimeException e) {
audit.record(sessionId, profile.getProvider(), profile.getModel(), null,
false, e.getMessage(), System.currentTimeMillis() - startedAt);
throw e; // 失败也留痕,再把错误抛给上层
}
} long startedAt = System.currentTimeMillis();`
`#public Response chat(String sessionId, Profile profile, Prompt prompt) {
ChatModel model = providerMap.get(profile.getProvider()); // 按名字取模型
if (model == null) {
throw new ProviderNotFoundException(profile.getProvider());
}
var tools = adapter.toSpringAiTools(prompt.getAvailableTools()); // 只翻译,不执行
long startedAt = System.currentTimeMillis();
try {
Response resp = model.call(request(prompt, tools, /* autoExecuteTools */ false)); // 关掉自动执行
audit.record(sessionId, profile.getProvider(), profile.getModel(), resp.usage(),
true, null, System.currentTimeMillis() - startedAt);
return resp; // 响应里可能带工具调用请求,原样交回上层
} catch (RuntimeException e) {
audit.record(sessionId, profile.getProvider(), profile.getModel(), null,
false, e.getMessage(), System.currentTimeMillis() - startedAt);
throw e; // 失败也留痕,再把错误抛给上层
}
} try {`
`#public Response chat(String sessionId, Profile profile, Prompt prompt) {
ChatModel model = providerMap.get(profile.getProvider()); // 按名字取模型
if (model == null) {
throw new ProviderNotFoundException(profile.getProvider());
}
var tools = adapter.toSpringAiTools(prompt.getAvailableTools()); // 只翻译,不执行
long startedAt = System.currentTimeMillis();
try {
Response resp = model.call(request(prompt, tools, /* autoExecuteTools */ false)); // 关掉自动执行
audit.record(sessionId, profile.getProvider(), profile.getModel(), resp.usage(),
true, null, System.currentTimeMillis() - startedAt);
return resp; // 响应里可能带工具调用请求,原样交回上层
} catch (RuntimeException e) {
audit.record(sessionId, profile.getProvider(), profile.getModel(), null,
false, e.getMessage(), System.currentTimeMillis() - startedAt);
throw e; // 失败也留痕,再把错误抛给上层
}
} Response resp = model.call(request(prompt, tools, /* autoExecuteTools */ false)); // 关掉自动执行`
`#public Response chat(String sessionId, Profile profile, Prompt prompt) {
ChatModel model = providerMap.get(profile.getProvider()); // 按名字取模型
if (model == null) {
throw new ProviderNotFoundException(profile.getProvider());
}
var tools = adapter.toSpringAiTools(prompt.getAvailableTools()); // 只翻译,不执行
long startedAt = System.currentTimeMillis();
try {
Response resp = model.call(request(prompt, tools, /* autoExecuteTools */ false)); // 关掉自动执行
audit.record(sessionId, profile.getProvider(), profile.getModel(), resp.usage(),
true, null, System.currentTimeMillis() - startedAt);
return resp; // 响应里可能带工具调用请求,原样交回上层
} catch (RuntimeException e) {
audit.record(sessionId, profile.getProvider(), profile.getModel(), null,
false, e.getMessage(), System.currentTimeMillis() - startedAt);
throw e; // 失败也留痕,再把错误抛给上层
}
} audit.record(sessionId, profile.getProvider(), profile.getModel(), resp.usage(),`
`#public Response chat(String sessionId, Profile profile, Prompt prompt) {
ChatModel model = providerMap.get(profile.getProvider()); // 按名字取模型
if (model == null) {
throw new ProviderNotFoundException(profile.getProvider());
}
var tools = adapter.toSpringAiTools(prompt.getAvailableTools()); // 只翻译,不执行
long startedAt = System.currentTimeMillis();
try {
Response resp = model.call(request(prompt, tools, /* autoExecuteTools */ false)); // 关掉自动执行
audit.record(sessionId, profile.getProvider(), profile.getModel(), resp.usage(),
true, null, System.currentTimeMillis() - startedAt);
return resp; // 响应里可能带工具调用请求,原样交回上层
} catch (RuntimeException e) {
audit.record(sessionId, profile.getProvider(), profile.getModel(), null,
false, e.getMessage(), System.currentTimeMillis() - startedAt);
throw e; // 失败也留痕,再把错误抛给上层
}
} true, null, System.currentTimeMillis() - startedAt);`
`#public Response chat(String sessionId, Profile profile, Prompt prompt) {
ChatModel model = providerMap.get(profile.getProvider()); // 按名字取模型
if (model == null) {
throw new ProviderNotFoundException(profile.getProvider());
}
var tools = adapter.toSpringAiTools(prompt.getAvailableTools()); // 只翻译,不执行
long startedAt = System.currentTimeMillis();
try {
Response resp = model.call(request(prompt, tools, /* autoExecuteTools */ false)); // 关掉自动执行
audit.record(sessionId, profile.getProvider(), profile.getModel(), resp.usage(),
true, null, System.currentTimeMillis() - startedAt);
return resp; // 响应里可能带工具调用请求,原样交回上层
} catch (RuntimeException e) {
audit.record(sessionId, profile.getProvider(), profile.getModel(), null,
false, e.getMessage(), System.currentTimeMillis() - startedAt);
throw e; // 失败也留痕,再把错误抛给上层
}
} return resp; // 响应里可能带工具调用请求,原样交回上层`
`#public Response chat(String sessionId, Profile profile, Prompt prompt) {
ChatModel model = providerMap.get(profile.getProvider()); // 按名字取模型
if (model == null) {
throw new ProviderNotFoundException(profile.getProvider());
}
var tools = adapter.toSpringAiTools(prompt.getAvailableTools()); // 只翻译,不执行
long startedAt = System.currentTimeMillis();
try {
Response resp = model.call(request(prompt, tools, /* autoExecuteTools */ false)); // 关掉自动执行
audit.record(sessionId, profile.getProvider(), profile.getModel(), resp.usage(),
true, null, System.currentTimeMillis() - startedAt);
return resp; // 响应里可能带工具调用请求,原样交回上层
} catch (RuntimeException e) {
audit.record(sessionId, profile.getProvider(), profile.getModel(), null,
false, e.getMessage(), System.currentTimeMillis() - startedAt);
throw e; // 失败也留痕,再把错误抛给上层
}
} } catch (RuntimeException e) {`
`#public Response chat(String sessionId, Profile profile, Prompt prompt) {
ChatModel model = providerMap.get(profile.getProvider()); // 按名字取模型
if (model == null) {
throw new ProviderNotFoundException(profile.getProvider());
}
var tools = adapter.toSpringAiTools(prompt.getAvailableTools()); // 只翻译,不执行
long startedAt = System.currentTimeMillis();
try {
Response resp = model.call(request(prompt, tools, /* autoExecuteTools */ false)); // 关掉自动执行
audit.record(sessionId, profile.getProvider(), profile.getModel(), resp.usage(),
true, null, System.currentTimeMillis() - startedAt);
return resp; // 响应里可能带工具调用请求,原样交回上层
} catch (RuntimeException e) {
audit.record(sessionId, profile.getProvider(), profile.getModel(), null,
false, e.getMessage(), System.currentTimeMillis() - startedAt);
throw e; // 失败也留痕,再把错误抛给上层
}
} audit.record(sessionId, profile.getProvider(), profile.getModel(), null,`
`#public Response chat(String sessionId, Profile profile, Prompt prompt) {
ChatModel model = providerMap.get(profile.getProvider()); // 按名字取模型
if (model == null) {
throw new ProviderNotFoundException(profile.getProvider());
}
var tools = adapter.toSpringAiTools(prompt.getAvailableTools()); // 只翻译,不执行
long startedAt = System.currentTimeMillis();
try {
Response resp = model.call(request(prompt, tools, /* autoExecuteTools */ false)); // 关掉自动执行
audit.record(sessionId, profile.getProvider(), profile.getModel(), resp.usage(),
true, null, System.currentTimeMillis() - startedAt);
return resp; // 响应里可能带工具调用请求,原样交回上层
} catch (RuntimeException e) {
audit.record(sessionId, profile.getProvider(), profile.getModel(), null,
false, e.getMessage(), System.currentTimeMillis() - startedAt);
throw e; // 失败也留痕,再把错误抛给上层
}
} false, e.getMessage(), System.currentTimeMillis() - startedAt);`
`#public Response chat(String sessionId, Profile profile, Prompt prompt) {
ChatModel model = providerMap.get(profile.getProvider()); // 按名字取模型
if (model == null) {
throw new ProviderNotFoundException(profile.getProvider());
}
var tools = adapter.toSpringAiTools(prompt.getAvailableTools()); // 只翻译,不执行
long startedAt = System.currentTimeMillis();
try {
Response resp = model.call(request(prompt, tools, /* autoExecuteTools */ false)); // 关掉自动执行
audit.record(sessionId, profile.getProvider(), profile.getModel(), resp.usage(),
true, null, System.currentTimeMillis() - startedAt);
return resp; // 响应里可能带工具调用请求,原样交回上层
} catch (RuntimeException e) {
audit.record(sessionId, profile.getProvider(), profile.getModel(), null,
false, e.getMessage(), System.currentTimeMillis() - startedAt);
throw e; // 失败也留痕,再把错误抛给上层
}
} throw e; // 失败也留痕,再把错误抛给上层`
`#public Response chat(String sessionId, Profile profile, Prompt prompt) {
ChatModel model = providerMap.get(profile.getProvider()); // 按名字取模型
if (model == null) {
throw new ProviderNotFoundException(profile.getProvider());
}
var tools = adapter.toSpringAiTools(prompt.getAvailableTools()); // 只翻译,不执行
long startedAt = System.currentTimeMillis();
try {
Response resp = model.call(request(prompt, tools, /* autoExecuteTools */ false)); // 关掉自动执行
audit.record(sessionId, profile.getProvider(), profile.getModel(), resp.usage(),
true, null, System.currentTimeMillis() - startedAt);
return resp; // 响应里可能带工具调用请求,原样交回上层
} catch (RuntimeException e) {
audit.record(sessionId, profile.getProvider(), profile.getModel(), null,
false, e.getMessage(), System.currentTimeMillis() - startedAt);
throw e; // 失败也留痕,再把错误抛给上层
}
} }`
`#public Response chat(String sessionId, Profile profile, Prompt prompt) {
ChatModel model = providerMap.get(profile.getProvider()); // 按名字取模型
if (model == null) {
throw new ProviderNotFoundException(profile.getProvider());
}
var tools = adapter.toSpringAiTools(prompt.getAvailableTools()); // 只翻译,不执行
long startedAt = System.currentTimeMillis();
try {
Response resp = model.call(request(prompt, tools, /* autoExecuteTools */ false)); // 关掉自动执行
audit.record(sessionId, profile.getProvider(), profile.getModel(), resp.usage(),
true, null, System.currentTimeMillis() - startedAt);
return resp; // 响应里可能带工具调用请求,原样交回上层
} catch (RuntimeException e) {
audit.record(sessionId, profile.getProvider(), profile.getModel(), null,
false, e.getMessage(), System.currentTimeMillis() - startedAt);
throw e; // 失败也留痕,再把错误抛给上层
}
}}
-
关键点
- 多传一个
sessionId:审计记录落到llm_calls表,按 session 关联 - 映射表取不到模型直接抛异常,别让它悄悄用了个错的
request(..., false)的false就是关掉自动执行,确切写法以所用 Spring AI 版本为准- 只记成功不记失败,一次真实事故在系统里就完全没留下痕迹
- 多传一个
-
第四步:工具适配和审计
- 适配器把
AgentTool.getInputSchema()转成 Spring AI 的工具描述,还是只翻译、不执行 llm_calls表必须有success/error_message两列,和工具侧的tool_invocations对称- SQLite 的
ALTER TABLE很弱:手工维护建表脚本,别指望hibernate.ddl-auto=update做迁移
- 适配器把
-
先别做
- fallback(一家挂了换另一家)、hedge racing(同时发几家抢最快)、熔断、成本看板都放到扩展阶段
- 核心阶段的目标是"能稳定调通一次",故障直接抛给上层
Provider 验收 harness
简介:SDD 给目标,Harness 给边界;AI 写的实现对不对,靠一套先定好的测试跑绿
-
思路
- 测试清单在实现之前(或同时)定下来,
/speckit-implement产出的代码必须让它们全绿 - 这就是"验收"的工程化形态,不靠人盯着代码看
- 测试清单在实现之前(或同时)定下来,
-
分层:判断标准是要不要碰真实网络
| 层 | 内容 | 怎么跑 |
|---|---|---|
| 单测(主体) | 路由、校验、审计、翻译,ChatModel 全部 mock |
默认全跑,秒级完成,不花钱、不依赖网络 |
| 集成冒烟 | 真调一次模型,验证 key 对、依赖对、真的通 | 打 @Tag("integration"),本地手动跑,CI 默认跳过 |
-
不能让外部 API 的可用性,变成自己流水线的可用性
-
测试类与验收点
| 测试类 | 覆盖的验收点 |
|---|---|
ProfileLoaderTest |
合法 YAML 全字段解析;引用不存在的 provider 报错清晰;坏文件不阻断其余加载;${ENV} 占位从环境变量解析 |
ProviderServiceTest |
双 provider 按名路由不串台;未知名抛异常;成功 / 失败都落审计;自动执行已关闭 |
ToolSchemaAdapterTest |
schema 翻译后字段一一对齐;产物里不含任何执行逻辑 |
LlmCallRepositoryTest |
手工建表脚本建出的 llm_calls 能存能读,两列真实存在 |
ProviderSmokeIT |
真 key 真调一次,拿到非空响应且 llm_calls 多一条 success=true |
- 最值钱的测试
java
#@Test
void 按名路由_两个provider不串台() {
var deepseek = mock(ChatModel.class);
var kimi = mock(ChatModel.class);
var service = new ProviderService(Map.of("deepseek", deepseek, "kimi", kimi), adapter, audit);
service.chat("s-1", profileUsing("kimi"), prompt);
verify(kimi, times(1)).call(any()); // 调的是 kimi
verify(deepseek, never()).call(any()); // deepseek 一次都没被碰
}
@Test
void 调用失败_审计必须留下success为false的记录() {
when(chatModel.call(any())).thenThrow(new RuntimeException("connect timeout"));
assertThrows(RuntimeException.class,
() -> service.chat("s-1", profileUsing("deepseek"), prompt)); // 异常继续上抛
verify(audit).record(eq("s-1"), eq("deepseek"), any(), isNull(),
eq(false), contains("timeout"), anyLong()); // 但审计先落了账
}@Test`
`#@Test
void 按名路由_两个provider不串台() {
var deepseek = mock(ChatModel.class);
var kimi = mock(ChatModel.class);
var service = new ProviderService(Map.of("deepseek", deepseek, "kimi", kimi), adapter, audit);
service.chat("s-1", profileUsing("kimi"), prompt);
verify(kimi, times(1)).call(any()); // 调的是 kimi
verify(deepseek, never()).call(any()); // deepseek 一次都没被碰
}
@Test
void 调用失败_审计必须留下success为false的记录() {
when(chatModel.call(any())).thenThrow(new RuntimeException("connect timeout"));
assertThrows(RuntimeException.class,
() -> service.chat("s-1", profileUsing("deepseek"), prompt)); // 异常继续上抛
verify(audit).record(eq("s-1"), eq("deepseek"), any(), isNull(),
eq(false), contains("timeout"), anyLong()); // 但审计先落了账
}void 按名路由_两个provider不串台() {`
`#@Test
void 按名路由_两个provider不串台() {
var deepseek = mock(ChatModel.class);
var kimi = mock(ChatModel.class);
var service = new ProviderService(Map.of("deepseek", deepseek, "kimi", kimi), adapter, audit);
service.chat("s-1", profileUsing("kimi"), prompt);
verify(kimi, times(1)).call(any()); // 调的是 kimi
verify(deepseek, never()).call(any()); // deepseek 一次都没被碰
}
@Test
void 调用失败_审计必须留下success为false的记录() {
when(chatModel.call(any())).thenThrow(new RuntimeException("connect timeout"));
assertThrows(RuntimeException.class,
() -> service.chat("s-1", profileUsing("deepseek"), prompt)); // 异常继续上抛
verify(audit).record(eq("s-1"), eq("deepseek"), any(), isNull(),
eq(false), contains("timeout"), anyLong()); // 但审计先落了账
} var deepseek = mock(ChatModel.class);`
`#@Test
void 按名路由_两个provider不串台() {
var deepseek = mock(ChatModel.class);
var kimi = mock(ChatModel.class);
var service = new ProviderService(Map.of("deepseek", deepseek, "kimi", kimi), adapter, audit);
service.chat("s-1", profileUsing("kimi"), prompt);
verify(kimi, times(1)).call(any()); // 调的是 kimi
verify(deepseek, never()).call(any()); // deepseek 一次都没被碰
}
@Test
void 调用失败_审计必须留下success为false的记录() {
when(chatModel.call(any())).thenThrow(new RuntimeException("connect timeout"));
assertThrows(RuntimeException.class,
() -> service.chat("s-1", profileUsing("deepseek"), prompt)); // 异常继续上抛
verify(audit).record(eq("s-1"), eq("deepseek"), any(), isNull(),
eq(false), contains("timeout"), anyLong()); // 但审计先落了账
} var kimi = mock(ChatModel.class);`
`#@Test
void 按名路由_两个provider不串台() {
var deepseek = mock(ChatModel.class);
var kimi = mock(ChatModel.class);
var service = new ProviderService(Map.of("deepseek", deepseek, "kimi", kimi), adapter, audit);
service.chat("s-1", profileUsing("kimi"), prompt);
verify(kimi, times(1)).call(any()); // 调的是 kimi
verify(deepseek, never()).call(any()); // deepseek 一次都没被碰
}
@Test
void 调用失败_审计必须留下success为false的记录() {
when(chatModel.call(any())).thenThrow(new RuntimeException("connect timeout"));
assertThrows(RuntimeException.class,
() -> service.chat("s-1", profileUsing("deepseek"), prompt)); // 异常继续上抛
verify(audit).record(eq("s-1"), eq("deepseek"), any(), isNull(),
eq(false), contains("timeout"), anyLong()); // 但审计先落了账
} var service = new ProviderService(Map.of("deepseek", deepseek, "kimi", kimi), adapter, audit);`
`#@Test
void 按名路由_两个provider不串台() {
var deepseek = mock(ChatModel.class);
var kimi = mock(ChatModel.class);
var service = new ProviderService(Map.of("deepseek", deepseek, "kimi", kimi), adapter, audit);
service.chat("s-1", profileUsing("kimi"), prompt);
verify(kimi, times(1)).call(any()); // 调的是 kimi
verify(deepseek, never()).call(any()); // deepseek 一次都没被碰
}
@Test
void 调用失败_审计必须留下success为false的记录() {
when(chatModel.call(any())).thenThrow(new RuntimeException("connect timeout"));
assertThrows(RuntimeException.class,
() -> service.chat("s-1", profileUsing("deepseek"), prompt)); // 异常继续上抛
verify(audit).record(eq("s-1"), eq("deepseek"), any(), isNull(),
eq(false), contains("timeout"), anyLong()); // 但审计先落了账
} service.chat("s-1", profileUsing("kimi"), prompt);`
`#@Test
void 按名路由_两个provider不串台() {
var deepseek = mock(ChatModel.class);
var kimi = mock(ChatModel.class);
var service = new ProviderService(Map.of("deepseek", deepseek, "kimi", kimi), adapter, audit);
service.chat("s-1", profileUsing("kimi"), prompt);
verify(kimi, times(1)).call(any()); // 调的是 kimi
verify(deepseek, never()).call(any()); // deepseek 一次都没被碰
}
@Test
void 调用失败_审计必须留下success为false的记录() {
when(chatModel.call(any())).thenThrow(new RuntimeException("connect timeout"));
assertThrows(RuntimeException.class,
() -> service.chat("s-1", profileUsing("deepseek"), prompt)); // 异常继续上抛
verify(audit).record(eq("s-1"), eq("deepseek"), any(), isNull(),
eq(false), contains("timeout"), anyLong()); // 但审计先落了账
} verify(kimi, times(1)).call(any()); // 调的是 kimi`
`#@Test
void 按名路由_两个provider不串台() {
var deepseek = mock(ChatModel.class);
var kimi = mock(ChatModel.class);
var service = new ProviderService(Map.of("deepseek", deepseek, "kimi", kimi), adapter, audit);
service.chat("s-1", profileUsing("kimi"), prompt);
verify(kimi, times(1)).call(any()); // 调的是 kimi
verify(deepseek, never()).call(any()); // deepseek 一次都没被碰
}
@Test
void 调用失败_审计必须留下success为false的记录() {
when(chatModel.call(any())).thenThrow(new RuntimeException("connect timeout"));
assertThrows(RuntimeException.class,
() -> service.chat("s-1", profileUsing("deepseek"), prompt)); // 异常继续上抛
verify(audit).record(eq("s-1"), eq("deepseek"), any(), isNull(),
eq(false), contains("timeout"), anyLong()); // 但审计先落了账
} verify(deepseek, never()).call(any()); // deepseek 一次都没被碰`
`#@Test
void 按名路由_两个provider不串台() {
var deepseek = mock(ChatModel.class);
var kimi = mock(ChatModel.class);
var service = new ProviderService(Map.of("deepseek", deepseek, "kimi", kimi), adapter, audit);
service.chat("s-1", profileUsing("kimi"), prompt);
verify(kimi, times(1)).call(any()); // 调的是 kimi
verify(deepseek, never()).call(any()); // deepseek 一次都没被碰
}
@Test
void 调用失败_审计必须留下success为false的记录() {
when(chatModel.call(any())).thenThrow(new RuntimeException("connect timeout"));
assertThrows(RuntimeException.class,
() -> service.chat("s-1", profileUsing("deepseek"), prompt)); // 异常继续上抛
verify(audit).record(eq("s-1"), eq("deepseek"), any(), isNull(),
eq(false), contains("timeout"), anyLong()); // 但审计先落了账
}}`
`#@Test
void 按名路由_两个provider不串台() {
var deepseek = mock(ChatModel.class);
var kimi = mock(ChatModel.class);
var service = new ProviderService(Map.of("deepseek", deepseek, "kimi", kimi), adapter, audit);
service.chat("s-1", profileUsing("kimi"), prompt);
verify(kimi, times(1)).call(any()); // 调的是 kimi
verify(deepseek, never()).call(any()); // deepseek 一次都没被碰
}
@Test
void 调用失败_审计必须留下success为false的记录() {
when(chatModel.call(any())).thenThrow(new RuntimeException("connect timeout"));
assertThrows(RuntimeException.class,
() -> service.chat("s-1", profileUsing("deepseek"), prompt)); // 异常继续上抛
verify(audit).record(eq("s-1"), eq("deepseek"), any(), isNull(),
eq(false), contains("timeout"), anyLong()); // 但审计先落了账
}`
`#@Test
void 按名路由_两个provider不串台() {
var deepseek = mock(ChatModel.class);
var kimi = mock(ChatModel.class);
var service = new ProviderService(Map.of("deepseek", deepseek, "kimi", kimi), adapter, audit);
service.chat("s-1", profileUsing("kimi"), prompt);
verify(kimi, times(1)).call(any()); // 调的是 kimi
verify(deepseek, never()).call(any()); // deepseek 一次都没被碰
}
@Test
void 调用失败_审计必须留下success为false的记录() {
when(chatModel.call(any())).thenThrow(new RuntimeException("connect timeout"));
assertThrows(RuntimeException.class,
() -> service.chat("s-1", profileUsing("deepseek"), prompt)); // 异常继续上抛
verify(audit).record(eq("s-1"), eq("deepseek"), any(), isNull(),
eq(false), contains("timeout"), anyLong()); // 但审计先落了账
}@Test`
`#@Test
void 按名路由_两个provider不串台() {
var deepseek = mock(ChatModel.class);
var kimi = mock(ChatModel.class);
var service = new ProviderService(Map.of("deepseek", deepseek, "kimi", kimi), adapter, audit);
service.chat("s-1", profileUsing("kimi"), prompt);
verify(kimi, times(1)).call(any()); // 调的是 kimi
verify(deepseek, never()).call(any()); // deepseek 一次都没被碰
}
@Test
void 调用失败_审计必须留下success为false的记录() {
when(chatModel.call(any())).thenThrow(new RuntimeException("connect timeout"));
assertThrows(RuntimeException.class,
() -> service.chat("s-1", profileUsing("deepseek"), prompt)); // 异常继续上抛
verify(audit).record(eq("s-1"), eq("deepseek"), any(), isNull(),
eq(false), contains("timeout"), anyLong()); // 但审计先落了账
}void 调用失败_审计必须留下success为false的记录() {`
`#@Test
void 按名路由_两个provider不串台() {
var deepseek = mock(ChatModel.class);
var kimi = mock(ChatModel.class);
var service = new ProviderService(Map.of("deepseek", deepseek, "kimi", kimi), adapter, audit);
service.chat("s-1", profileUsing("kimi"), prompt);
verify(kimi, times(1)).call(any()); // 调的是 kimi
verify(deepseek, never()).call(any()); // deepseek 一次都没被碰
}
@Test
void 调用失败_审计必须留下success为false的记录() {
when(chatModel.call(any())).thenThrow(new RuntimeException("connect timeout"));
assertThrows(RuntimeException.class,
() -> service.chat("s-1", profileUsing("deepseek"), prompt)); // 异常继续上抛
verify(audit).record(eq("s-1"), eq("deepseek"), any(), isNull(),
eq(false), contains("timeout"), anyLong()); // 但审计先落了账
} when(chatModel.call(any())).thenThrow(new RuntimeException("connect timeout"));`
`#@Test
void 按名路由_两个provider不串台() {
var deepseek = mock(ChatModel.class);
var kimi = mock(ChatModel.class);
var service = new ProviderService(Map.of("deepseek", deepseek, "kimi", kimi), adapter, audit);
service.chat("s-1", profileUsing("kimi"), prompt);
verify(kimi, times(1)).call(any()); // 调的是 kimi
verify(deepseek, never()).call(any()); // deepseek 一次都没被碰
}
@Test
void 调用失败_审计必须留下success为false的记录() {
when(chatModel.call(any())).thenThrow(new RuntimeException("connect timeout"));
assertThrows(RuntimeException.class,
() -> service.chat("s-1", profileUsing("deepseek"), prompt)); // 异常继续上抛
verify(audit).record(eq("s-1"), eq("deepseek"), any(), isNull(),
eq(false), contains("timeout"), anyLong()); // 但审计先落了账
} assertThrows(RuntimeException.class,`
`#@Test
void 按名路由_两个provider不串台() {
var deepseek = mock(ChatModel.class);
var kimi = mock(ChatModel.class);
var service = new ProviderService(Map.of("deepseek", deepseek, "kimi", kimi), adapter, audit);
service.chat("s-1", profileUsing("kimi"), prompt);
verify(kimi, times(1)).call(any()); // 调的是 kimi
verify(deepseek, never()).call(any()); // deepseek 一次都没被碰
}
@Test
void 调用失败_审计必须留下success为false的记录() {
when(chatModel.call(any())).thenThrow(new RuntimeException("connect timeout"));
assertThrows(RuntimeException.class,
() -> service.chat("s-1", profileUsing("deepseek"), prompt)); // 异常继续上抛
verify(audit).record(eq("s-1"), eq("deepseek"), any(), isNull(),
eq(false), contains("timeout"), anyLong()); // 但审计先落了账
} () -> service.chat("s-1", profileUsing("deepseek"), prompt)); // 异常继续上抛`
`#@Test
void 按名路由_两个provider不串台() {
var deepseek = mock(ChatModel.class);
var kimi = mock(ChatModel.class);
var service = new ProviderService(Map.of("deepseek", deepseek, "kimi", kimi), adapter, audit);
service.chat("s-1", profileUsing("kimi"), prompt);
verify(kimi, times(1)).call(any()); // 调的是 kimi
verify(deepseek, never()).call(any()); // deepseek 一次都没被碰
}
@Test
void 调用失败_审计必须留下success为false的记录() {
when(chatModel.call(any())).thenThrow(new RuntimeException("connect timeout"));
assertThrows(RuntimeException.class,
() -> service.chat("s-1", profileUsing("deepseek"), prompt)); // 异常继续上抛
verify(audit).record(eq("s-1"), eq("deepseek"), any(), isNull(),
eq(false), contains("timeout"), anyLong()); // 但审计先落了账
} verify(audit).record(eq("s-1"), eq("deepseek"), any(), isNull(),`
`#@Test
void 按名路由_两个provider不串台() {
var deepseek = mock(ChatModel.class);
var kimi = mock(ChatModel.class);
var service = new ProviderService(Map.of("deepseek", deepseek, "kimi", kimi), adapter, audit);
service.chat("s-1", profileUsing("kimi"), prompt);
verify(kimi, times(1)).call(any()); // 调的是 kimi
verify(deepseek, never()).call(any()); // deepseek 一次都没被碰
}
@Test
void 调用失败_审计必须留下success为false的记录() {
when(chatModel.call(any())).thenThrow(new RuntimeException("connect timeout"));
assertThrows(RuntimeException.class,
() -> service.chat("s-1", profileUsing("deepseek"), prompt)); // 异常继续上抛
verify(audit).record(eq("s-1"), eq("deepseek"), any(), isNull(),
eq(false), contains("timeout"), anyLong()); // 但审计先落了账
} eq(false), contains("timeout"), anyLong()); // 但审计先落了账`
`#@Test
void 按名路由_两个provider不串台() {
var deepseek = mock(ChatModel.class);
var kimi = mock(ChatModel.class);
var service = new ProviderService(Map.of("deepseek", deepseek, "kimi", kimi), adapter, audit);
service.chat("s-1", profileUsing("kimi"), prompt);
verify(kimi, times(1)).call(any()); // 调的是 kimi
verify(deepseek, never()).call(any()); // deepseek 一次都没被碰
}
@Test
void 调用失败_审计必须留下success为false的记录() {
when(chatModel.call(any())).thenThrow(new RuntimeException("connect timeout"));
assertThrows(RuntimeException.class,
() -> service.chat("s-1", profileUsing("deepseek"), prompt)); // 异常继续上抛
verify(audit).record(eq("s-1"), eq("deepseek"), any(), isNull(),
eq(false), contains("timeout"), anyLong()); // 但审计先落了账
}}
-
关键点
-
每个"想清楚"阶段点过名的坑,都要有一个对应的回归测试,坑才被永久钉死
-
另一个关键测试用
ArgumentCaptor抓请求,断言autoExecuteTools()为 false、tools()不为空 -
失败审计断言的不是"没抛异常",而是"抛了异常并且审计先落了账"
-
仓库测试建表要执行手工脚本
schema.sql,不让 Hibernate 自动建,否则测试绿了、生产列名对不上日常:只跑单测,全绿才算实现完成
DEEPSEEK_API_KEY=xxx mvn test -Dgroups=integration # 手动:冒烟验证真连通mvn test # 日常:只跑单测,全绿才算实现完成
日常:只跑单测,全绿才算实现完成
DEEPSEEK_API_KEY=xxx mvn test -Dgroups=integration # 手动:冒烟验证真连通DEEPSEEK_API_KEY=xxx mvn test -Dgroups=integration # 手动:冒烟验证真连通
-
-
人工确认
- 用到的 starter 依赖已确认在锁定的 BOM 里能下载、能解析
- 集成冒烟真跑过一次,拿到过真实响应
- key 走环境变量:
grep -r "sk-"在代码和配置里搜不到明文 - Provider 没有独立入口,要和 ReAct 一起撑起"每日天气"Demo 里的那次模型调用
ReAct:想一步、做一步、看结果
简介:ReAct 让大模型在循环里反复推理、行动、观察,直到把事办成,是 Agent 最关键的一段代码
- 核心概念
- ReAct 就是让大模型像人做事一样,在一个循环里反复"想一步、做一步、看结果",直到把事办成
- 单独调一次大模型只是 chatbot,你问一句它答一句
- "看看今天天气,帮我决定穿什么":模型得先查天气、拿到结果、再根据结果给建议
- ReAct(Reasoning + Acting)2022 年提出,现在是事实标准,Claude Code、Cursor、LangChain 跑的都是它

-
什么时候停
- 模型这轮没提出要调工具:说明它能给最终答复了,循环返回结果
- 模型一直要调工具停不下来:靠"最大轮数"兜底,转够了强制收尾
-
在 Agent 里的位置
- ReAct 是"大脑循环",自己不调模型、也不执行工具,只负责指挥
- 想的时候通过 Provider 调一次大模型,做的时候把工具交给 ToolExecutor
ReAct 动手前先想清楚的事
简介:主循环只有几十行,难的不是写个 while,而是循环里要照顾的边界
- 职责拆干净:循环只做调度
| ReActLoop 负责 | 交给别人 |
|---|---|
| 一圈圈转(调度) | 拼上下文 → PromptBuilder |
| 判断这轮该不该停 | 调大模型 → ProviderService |
| 把响应和结果累积到 Session | 执行工具 → ToolExecutor |
-
循环里塞的东西越少,越好读、越不容易出 bug
-
为什么自己写,不用框架现成的循环
- 循环恰恰是 Agent 最需要自己掌控的地方:什么时候停、工具失败怎么办、上下文太长怎么压、哪几步换个模型
- 用框架的黑盒,这些都动不了,所以核心阶段自己写这几十行
-
三个坑
| 坑 | 后果 | 解法 |
|---|---|---|
| 不设轮数上限 | 模型反复要调工具,陷进死循环 | 最大轮数,默认 10 |
| 不管上下文长度 | 每次调模型都带全部历史,几轮就撑爆 context | 会话历史只留最近 N 轮,默认 20 |
| 不累积回 Session | 事后没法审计,下一轮也接不上前一轮 | 每轮的模型响应和工具结果都追加回 Session |
- 停止、错误、上下文这些边界是设计时就要定死的,不是写完再补
ReAct 代码实现
简介:ReActLoop 管调度、PromptBuilder 管拼上下文、ToolExecutor 管执行工具,AgentService 在最上层编排

- 主角 ReActLoop:输入 Session(这次对话的全部状态)+ 用户这句话,输出最终响应
java
#public String run(Session session, String userMessage, Profile profile) {
session.append(userMessage);
for (int i = 0; i < profile.maxIterations(); i++) { // 默认 10,防死循环
Prompt prompt = promptBuilder.build(session, profile);
Response resp = providerService.chat(session.id(), profile, prompt); // 带 sessionId,供审计关联
session.append(resp); // 累积,可审计
if (!resp.hasToolCalls()) {
return resp.text(); // 没有工具调用,收尾
}
for (ToolCall call : resp.toolCalls()) {
ToolResult result = toolExecutor.execute(session.id(), call); // 执行权在这
session.appendToolResult(result);
}
}
return "达到最大轮数,已停止";
}public String run(Session session, String userMessage, Profile profile) {`
`#public String run(Session session, String userMessage, Profile profile) {
session.append(userMessage);
for (int i = 0; i < profile.maxIterations(); i++) { // 默认 10,防死循环
Prompt prompt = promptBuilder.build(session, profile);
Response resp = providerService.chat(session.id(), profile, prompt); // 带 sessionId,供审计关联
session.append(resp); // 累积,可审计
if (!resp.hasToolCalls()) {
return resp.text(); // 没有工具调用,收尾
}
for (ToolCall call : resp.toolCalls()) {
ToolResult result = toolExecutor.execute(session.id(), call); // 执行权在这
session.appendToolResult(result);
}
}
return "达到最大轮数,已停止";
} session.append(userMessage);`
`#public String run(Session session, String userMessage, Profile profile) {
session.append(userMessage);
for (int i = 0; i < profile.maxIterations(); i++) { // 默认 10,防死循环
Prompt prompt = promptBuilder.build(session, profile);
Response resp = providerService.chat(session.id(), profile, prompt); // 带 sessionId,供审计关联
session.append(resp); // 累积,可审计
if (!resp.hasToolCalls()) {
return resp.text(); // 没有工具调用,收尾
}
for (ToolCall call : resp.toolCalls()) {
ToolResult result = toolExecutor.execute(session.id(), call); // 执行权在这
session.appendToolResult(result);
}
}
return "达到最大轮数,已停止";
} for (int i = 0; i < profile.maxIterations(); i++) { // 默认 10,防死循环`
`#public String run(Session session, String userMessage, Profile profile) {
session.append(userMessage);
for (int i = 0; i < profile.maxIterations(); i++) { // 默认 10,防死循环
Prompt prompt = promptBuilder.build(session, profile);
Response resp = providerService.chat(session.id(), profile, prompt); // 带 sessionId,供审计关联
session.append(resp); // 累积,可审计
if (!resp.hasToolCalls()) {
return resp.text(); // 没有工具调用,收尾
}
for (ToolCall call : resp.toolCalls()) {
ToolResult result = toolExecutor.execute(session.id(), call); // 执行权在这
session.appendToolResult(result);
}
}
return "达到最大轮数,已停止";
} Prompt prompt = promptBuilder.build(session, profile);`
`#public String run(Session session, String userMessage, Profile profile) {
session.append(userMessage);
for (int i = 0; i < profile.maxIterations(); i++) { // 默认 10,防死循环
Prompt prompt = promptBuilder.build(session, profile);
Response resp = providerService.chat(session.id(), profile, prompt); // 带 sessionId,供审计关联
session.append(resp); // 累积,可审计
if (!resp.hasToolCalls()) {
return resp.text(); // 没有工具调用,收尾
}
for (ToolCall call : resp.toolCalls()) {
ToolResult result = toolExecutor.execute(session.id(), call); // 执行权在这
session.appendToolResult(result);
}
}
return "达到最大轮数,已停止";
} Response resp = providerService.chat(session.id(), profile, prompt); // 带 sessionId,供审计关联`
`#public String run(Session session, String userMessage, Profile profile) {
session.append(userMessage);
for (int i = 0; i < profile.maxIterations(); i++) { // 默认 10,防死循环
Prompt prompt = promptBuilder.build(session, profile);
Response resp = providerService.chat(session.id(), profile, prompt); // 带 sessionId,供审计关联
session.append(resp); // 累积,可审计
if (!resp.hasToolCalls()) {
return resp.text(); // 没有工具调用,收尾
}
for (ToolCall call : resp.toolCalls()) {
ToolResult result = toolExecutor.execute(session.id(), call); // 执行权在这
session.appendToolResult(result);
}
}
return "达到最大轮数,已停止";
} session.append(resp); // 累积,可审计`
`#public String run(Session session, String userMessage, Profile profile) {
session.append(userMessage);
for (int i = 0; i < profile.maxIterations(); i++) { // 默认 10,防死循环
Prompt prompt = promptBuilder.build(session, profile);
Response resp = providerService.chat(session.id(), profile, prompt); // 带 sessionId,供审计关联
session.append(resp); // 累积,可审计
if (!resp.hasToolCalls()) {
return resp.text(); // 没有工具调用,收尾
}
for (ToolCall call : resp.toolCalls()) {
ToolResult result = toolExecutor.execute(session.id(), call); // 执行权在这
session.appendToolResult(result);
}
}
return "达到最大轮数,已停止";
} if (!resp.hasToolCalls()) {`
`#public String run(Session session, String userMessage, Profile profile) {
session.append(userMessage);
for (int i = 0; i < profile.maxIterations(); i++) { // 默认 10,防死循环
Prompt prompt = promptBuilder.build(session, profile);
Response resp = providerService.chat(session.id(), profile, prompt); // 带 sessionId,供审计关联
session.append(resp); // 累积,可审计
if (!resp.hasToolCalls()) {
return resp.text(); // 没有工具调用,收尾
}
for (ToolCall call : resp.toolCalls()) {
ToolResult result = toolExecutor.execute(session.id(), call); // 执行权在这
session.appendToolResult(result);
}
}
return "达到最大轮数,已停止";
} return resp.text(); // 没有工具调用,收尾`
`#public String run(Session session, String userMessage, Profile profile) {
session.append(userMessage);
for (int i = 0; i < profile.maxIterations(); i++) { // 默认 10,防死循环
Prompt prompt = promptBuilder.build(session, profile);
Response resp = providerService.chat(session.id(), profile, prompt); // 带 sessionId,供审计关联
session.append(resp); // 累积,可审计
if (!resp.hasToolCalls()) {
return resp.text(); // 没有工具调用,收尾
}
for (ToolCall call : resp.toolCalls()) {
ToolResult result = toolExecutor.execute(session.id(), call); // 执行权在这
session.appendToolResult(result);
}
}
return "达到最大轮数,已停止";
} }`
`#public String run(Session session, String userMessage, Profile profile) {
session.append(userMessage);
for (int i = 0; i < profile.maxIterations(); i++) { // 默认 10,防死循环
Prompt prompt = promptBuilder.build(session, profile);
Response resp = providerService.chat(session.id(), profile, prompt); // 带 sessionId,供审计关联
session.append(resp); // 累积,可审计
if (!resp.hasToolCalls()) {
return resp.text(); // 没有工具调用,收尾
}
for (ToolCall call : resp.toolCalls()) {
ToolResult result = toolExecutor.execute(session.id(), call); // 执行权在这
session.appendToolResult(result);
}
}
return "达到最大轮数,已停止";
} for (ToolCall call : resp.toolCalls()) {`
`#public String run(Session session, String userMessage, Profile profile) {
session.append(userMessage);
for (int i = 0; i < profile.maxIterations(); i++) { // 默认 10,防死循环
Prompt prompt = promptBuilder.build(session, profile);
Response resp = providerService.chat(session.id(), profile, prompt); // 带 sessionId,供审计关联
session.append(resp); // 累积,可审计
if (!resp.hasToolCalls()) {
return resp.text(); // 没有工具调用,收尾
}
for (ToolCall call : resp.toolCalls()) {
ToolResult result = toolExecutor.execute(session.id(), call); // 执行权在这
session.appendToolResult(result);
}
}
return "达到最大轮数,已停止";
} ToolResult result = toolExecutor.execute(session.id(), call); // 执行权在这`
`#public String run(Session session, String userMessage, Profile profile) {
session.append(userMessage);
for (int i = 0; i < profile.maxIterations(); i++) { // 默认 10,防死循环
Prompt prompt = promptBuilder.build(session, profile);
Response resp = providerService.chat(session.id(), profile, prompt); // 带 sessionId,供审计关联
session.append(resp); // 累积,可审计
if (!resp.hasToolCalls()) {
return resp.text(); // 没有工具调用,收尾
}
for (ToolCall call : resp.toolCalls()) {
ToolResult result = toolExecutor.execute(session.id(), call); // 执行权在这
session.appendToolResult(result);
}
}
return "达到最大轮数,已停止";
} session.appendToolResult(result);`
`#public String run(Session session, String userMessage, Profile profile) {
session.append(userMessage);
for (int i = 0; i < profile.maxIterations(); i++) { // 默认 10,防死循环
Prompt prompt = promptBuilder.build(session, profile);
Response resp = providerService.chat(session.id(), profile, prompt); // 带 sessionId,供审计关联
session.append(resp); // 累积,可审计
if (!resp.hasToolCalls()) {
return resp.text(); // 没有工具调用,收尾
}
for (ToolCall call : resp.toolCalls()) {
ToolResult result = toolExecutor.execute(session.id(), call); // 执行权在这
session.appendToolResult(result);
}
}
return "达到最大轮数,已停止";
} }`
`#public String run(Session session, String userMessage, Profile profile) {
session.append(userMessage);
for (int i = 0; i < profile.maxIterations(); i++) { // 默认 10,防死循环
Prompt prompt = promptBuilder.build(session, profile);
Response resp = providerService.chat(session.id(), profile, prompt); // 带 sessionId,供审计关联
session.append(resp); // 累积,可审计
if (!resp.hasToolCalls()) {
return resp.text(); // 没有工具调用,收尾
}
for (ToolCall call : resp.toolCalls()) {
ToolResult result = toolExecutor.execute(session.id(), call); // 执行权在这
session.appendToolResult(result);
}
}
return "达到最大轮数,已停止";
} }`
`#public String run(Session session, String userMessage, Profile profile) {
session.append(userMessage);
for (int i = 0; i < profile.maxIterations(); i++) { // 默认 10,防死循环
Prompt prompt = promptBuilder.build(session, profile);
Response resp = providerService.chat(session.id(), profile, prompt); // 带 sessionId,供审计关联
session.append(resp); // 累积,可审计
if (!resp.hasToolCalls()) {
return resp.text(); // 没有工具调用,收尾
}
for (ToolCall call : resp.toolCalls()) {
ToolResult result = toolExecutor.execute(session.id(), call); // 执行权在这
session.appendToolResult(result);
}
}
return "达到最大轮数,已停止";
} return "达到最大轮数,已停止";`
`#public String run(Session session, String userMessage, Profile profile) {
session.append(userMessage);
for (int i = 0; i < profile.maxIterations(); i++) { // 默认 10,防死循环
Prompt prompt = promptBuilder.build(session, profile);
Response resp = providerService.chat(session.id(), profile, prompt); // 带 sessionId,供审计关联
session.append(resp); // 累积,可审计
if (!resp.hasToolCalls()) {
return resp.text(); // 没有工具调用,收尾
}
for (ToolCall call : resp.toolCalls()) {
ToolResult result = toolExecutor.execute(session.id(), call); // 执行权在这
session.appendToolResult(result);
}
}
return "达到最大轮数,已停止";
}}
-
关键点
for (i < maxIterations)兜住死循环;session.append(resp)先存再判断,每轮都留痕providerService.chat和toolExecutor.execute都要传session.id(),因为llm_calls、tool_invocations都按 session 关联- 工具执行是 ToolExecutor 干的,不是循环本身,也不是 Provider 自动干的
-
配角一:PromptBuilder,每轮按顺序拼四部分
| 顺序 | 部分 | 说明 |
|---|---|---|
| 1 | system prompt | 角色设定 + 启动信息 + Skill,末尾附当前日期时间:模型自己不知道今天几号,定时场景里的"今天"全靠这一行 |
| 2 | 长期记忆 | Memory 模块提供的跨会话记忆,没开就跳过 |
| 3 | 会话历史 | 只留最近 N 轮(默认 20),超了就截断 |
| 4 | 可用工具列表 | 当前 Agent 能用的工具 |
-
注意:长期记忆是跨会话都在的东西,会话历史只是这一次对话到目前为止的往来记录,两者别混
-
配角二:ToolExecutor
- 从工具表找到模型要调的工具 → 过沙箱 / 白名单检查 → 执行 → 包成
ToolResult返回 - 成功、失败都写一条
tool_invocations(含success/error_message),和llm_calls同一口径 - 工具执行只在这一个地方发生,不能有第二条路,这也是 Provider 要关掉 Spring AI 自动执行的原因
- 从工具表找到模型要调的工具 → 过沙箱 / 白名单检查 → 执行 → 包成
-
编排者 AgentService
- CLI、Web、定时三种触发源最终都调同一个
AgentService.process
- CLI、Web、定时三种触发源最终都调同一个
java
#public String process(Session session, String userMessage) {
Profile profile = profileRegistry.get(session.profileName());
ProfileContext.set(profile); // 工具执行时靠它知道“当前是哪个 Agent”
try {
String reply = reActLoop.run(session, userMessage, profile);
sessionManager.save(session); // 把累积完的历史持久化
return reply;
} finally {
ProfileContext.clear(); // 用完必须清
}
}public String process(Session session, String userMessage) {`
`#public String process(Session session, String userMessage) {
Profile profile = profileRegistry.get(session.profileName());
ProfileContext.set(profile); // 工具执行时靠它知道“当前是哪个 Agent”
try {
String reply = reActLoop.run(session, userMessage, profile);
sessionManager.save(session); // 把累积完的历史持久化
return reply;
} finally {
ProfileContext.clear(); // 用完必须清
}
} Profile profile = profileRegistry.get(session.profileName());`
`#public String process(Session session, String userMessage) {
Profile profile = profileRegistry.get(session.profileName());
ProfileContext.set(profile); // 工具执行时靠它知道“当前是哪个 Agent”
try {
String reply = reActLoop.run(session, userMessage, profile);
sessionManager.save(session); // 把累积完的历史持久化
return reply;
} finally {
ProfileContext.clear(); // 用完必须清
}
} ProfileContext.set(profile); // 工具执行时靠它知道"当前是哪个 Agent"`
`#public String process(Session session, String userMessage) {
Profile profile = profileRegistry.get(session.profileName());
ProfileContext.set(profile); // 工具执行时靠它知道“当前是哪个 Agent”
try {
String reply = reActLoop.run(session, userMessage, profile);
sessionManager.save(session); // 把累积完的历史持久化
return reply;
} finally {
ProfileContext.clear(); // 用完必须清
}
} try {`
`#public String process(Session session, String userMessage) {
Profile profile = profileRegistry.get(session.profileName());
ProfileContext.set(profile); // 工具执行时靠它知道“当前是哪个 Agent”
try {
String reply = reActLoop.run(session, userMessage, profile);
sessionManager.save(session); // 把累积完的历史持久化
return reply;
} finally {
ProfileContext.clear(); // 用完必须清
}
} String reply = reActLoop.run(session, userMessage, profile);`
`#public String process(Session session, String userMessage) {
Profile profile = profileRegistry.get(session.profileName());
ProfileContext.set(profile); // 工具执行时靠它知道“当前是哪个 Agent”
try {
String reply = reActLoop.run(session, userMessage, profile);
sessionManager.save(session); // 把累积完的历史持久化
return reply;
} finally {
ProfileContext.clear(); // 用完必须清
}
} sessionManager.save(session); // 把累积完的历史持久化`
`#public String process(Session session, String userMessage) {
Profile profile = profileRegistry.get(session.profileName());
ProfileContext.set(profile); // 工具执行时靠它知道“当前是哪个 Agent”
try {
String reply = reActLoop.run(session, userMessage, profile);
sessionManager.save(session); // 把累积完的历史持久化
return reply;
} finally {
ProfileContext.clear(); // 用完必须清
}
} return reply;`
`#public String process(Session session, String userMessage) {
Profile profile = profileRegistry.get(session.profileName());
ProfileContext.set(profile); // 工具执行时靠它知道“当前是哪个 Agent”
try {
String reply = reActLoop.run(session, userMessage, profile);
sessionManager.save(session); // 把累积完的历史持久化
return reply;
} finally {
ProfileContext.clear(); // 用完必须清
}
} } finally {`
`#public String process(Session session, String userMessage) {
Profile profile = profileRegistry.get(session.profileName());
ProfileContext.set(profile); // 工具执行时靠它知道“当前是哪个 Agent”
try {
String reply = reActLoop.run(session, userMessage, profile);
sessionManager.save(session); // 把累积完的历史持久化
return reply;
} finally {
ProfileContext.clear(); // 用完必须清
}
} ProfileContext.clear(); // 用完必须清`
`#public String process(Session session, String userMessage) {
Profile profile = profileRegistry.get(session.profileName());
ProfileContext.set(profile); // 工具执行时靠它知道“当前是哪个 Agent”
try {
String reply = reActLoop.run(session, userMessage, profile);
sessionManager.save(session); // 把累积完的历史持久化
return reply;
} finally {
ProfileContext.clear(); // 用完必须清
}
} }`
`#public String process(Session session, String userMessage) {
Profile profile = profileRegistry.get(session.profileName());
ProfileContext.set(profile); // 工具执行时靠它知道“当前是哪个 Agent”
try {
String reply = reActLoop.run(session, userMessage, profile);
sessionManager.save(session); // 把累积完的历史持久化
return reply;
} finally {
ProfileContext.clear(); // 用完必须清
}
}}
-
为什么需要 ProfileContext(一个 ThreadLocal)
- 工具的
execute签名不带 Profile,但有的工具要读当前 Agent 的配置,比如 notify 要读notify_channels - 改工具接口签名代价太大:入口处放进 ThreadLocal,出口处清掉,工具想用就取
- 虚拟线程下每个请求独占一个线程,天然不串
- 工具的
-
供给者 ContextLoader:按 Profile 的 bootstrap 和 skills 字段读
.agent/下的文件,拼成 system prompt 的第一部分(Bootstrap 三件套 + SKILL.md)- 每次组装 prompt 都重新读文件、不缓存,用户改完立即生效
- 显式引用的文件缺失要报错,Bootstrap 缺失至少 WARN
- 静默跳过会造成"人格悄悄丢了"这种最难查的软故障
-
先别做
- 工具并行调用、Agent 之间互相委托、流式输出、上下文压缩都留到扩展阶段
- 上下文先用"只留最近 N 轮"顶着
ReAct 验收 harness
简介:这一块全部不碰网络,Provider、工具、文件系统都能 mock 或用临时目录,harness 全是单测
| 测试类 | 覆盖的验收点 |
|---|---|
ReActLoopTest |
无工具调用一轮收尾;有工具调用则执行并回填进下一轮;转满最大轮数强制停;每轮响应和工具结果都累积进 Session |
PromptBuilderTest |
四部分顺序正确;历史超 N 轮被截断;system prompt 末尾含当前日期时间 |
ToolExecutorTest |
成功写 success=true;失败也写 success=false 带原因,异常不吞 |
AgentServiceTest |
处理期间能取到当前 Profile;抛异常时 finally 也清掉;结束后 Session 被持久化 |
ContextLoaderTest |
改文件后下一次 build 立即读到新内容;Skill 引用缺失报错、Bootstrap 缺失 WARN |
java
#@Test
void 模型一直要调工具_转满最大轮数强制停() {
when(providerService.chat(any(), any(), any()))
.thenReturn(responseWithToolCall(httpGetCall)); // 每轮都要调工具,永不收敛
String reply = loop.run(session, "查天气", profileWithMaxIterations(10));
verify(providerService, times(10)).chat(any(), any(), any()); // 恰好 10 轮
assertTrue(reply.contains("达到最大轮数"));
}
@Test
void 处理中抛异常_ProfileContext也必须被清掉() {
when(reActLoop.run(any(), any(), any())).thenThrow(new RuntimeException("boom"));
assertThrows(RuntimeException.class, () -> agentService.process(session, "hi"));
assertNull(ProfileContext.current()); // 没清掉,复用此线程的下一个请求会拿到别人的 Profile
}@Test`
`#@Test
void 模型一直要调工具_转满最大轮数强制停() {
when(providerService.chat(any(), any(), any()))
.thenReturn(responseWithToolCall(httpGetCall)); // 每轮都要调工具,永不收敛
String reply = loop.run(session, "查天气", profileWithMaxIterations(10));
verify(providerService, times(10)).chat(any(), any(), any()); // 恰好 10 轮
assertTrue(reply.contains("达到最大轮数"));
}
@Test
void 处理中抛异常_ProfileContext也必须被清掉() {
when(reActLoop.run(any(), any(), any())).thenThrow(new RuntimeException("boom"));
assertThrows(RuntimeException.class, () -> agentService.process(session, "hi"));
assertNull(ProfileContext.current()); // 没清掉,复用此线程的下一个请求会拿到别人的 Profile
}void 模型一直要调工具_转满最大轮数强制停() {`
`#@Test
void 模型一直要调工具_转满最大轮数强制停() {
when(providerService.chat(any(), any(), any()))
.thenReturn(responseWithToolCall(httpGetCall)); // 每轮都要调工具,永不收敛
String reply = loop.run(session, "查天气", profileWithMaxIterations(10));
verify(providerService, times(10)).chat(any(), any(), any()); // 恰好 10 轮
assertTrue(reply.contains("达到最大轮数"));
}
@Test
void 处理中抛异常_ProfileContext也必须被清掉() {
when(reActLoop.run(any(), any(), any())).thenThrow(new RuntimeException("boom"));
assertThrows(RuntimeException.class, () -> agentService.process(session, "hi"));
assertNull(ProfileContext.current()); // 没清掉,复用此线程的下一个请求会拿到别人的 Profile
} when(providerService.chat(any(), any(), any()))`
`#@Test
void 模型一直要调工具_转满最大轮数强制停() {
when(providerService.chat(any(), any(), any()))
.thenReturn(responseWithToolCall(httpGetCall)); // 每轮都要调工具,永不收敛
String reply = loop.run(session, "查天气", profileWithMaxIterations(10));
verify(providerService, times(10)).chat(any(), any(), any()); // 恰好 10 轮
assertTrue(reply.contains("达到最大轮数"));
}
@Test
void 处理中抛异常_ProfileContext也必须被清掉() {
when(reActLoop.run(any(), any(), any())).thenThrow(new RuntimeException("boom"));
assertThrows(RuntimeException.class, () -> agentService.process(session, "hi"));
assertNull(ProfileContext.current()); // 没清掉,复用此线程的下一个请求会拿到别人的 Profile
} .thenReturn(responseWithToolCall(httpGetCall)); // 每轮都要调工具,永不收敛`
`#@Test
void 模型一直要调工具_转满最大轮数强制停() {
when(providerService.chat(any(), any(), any()))
.thenReturn(responseWithToolCall(httpGetCall)); // 每轮都要调工具,永不收敛
String reply = loop.run(session, "查天气", profileWithMaxIterations(10));
verify(providerService, times(10)).chat(any(), any(), any()); // 恰好 10 轮
assertTrue(reply.contains("达到最大轮数"));
}
@Test
void 处理中抛异常_ProfileContext也必须被清掉() {
when(reActLoop.run(any(), any(), any())).thenThrow(new RuntimeException("boom"));
assertThrows(RuntimeException.class, () -> agentService.process(session, "hi"));
assertNull(ProfileContext.current()); // 没清掉,复用此线程的下一个请求会拿到别人的 Profile
} String reply = loop.run(session, "查天气", profileWithMaxIterations(10));`
`#@Test
void 模型一直要调工具_转满最大轮数强制停() {
when(providerService.chat(any(), any(), any()))
.thenReturn(responseWithToolCall(httpGetCall)); // 每轮都要调工具,永不收敛
String reply = loop.run(session, "查天气", profileWithMaxIterations(10));
verify(providerService, times(10)).chat(any(), any(), any()); // 恰好 10 轮
assertTrue(reply.contains("达到最大轮数"));
}
@Test
void 处理中抛异常_ProfileContext也必须被清掉() {
when(reActLoop.run(any(), any(), any())).thenThrow(new RuntimeException("boom"));
assertThrows(RuntimeException.class, () -> agentService.process(session, "hi"));
assertNull(ProfileContext.current()); // 没清掉,复用此线程的下一个请求会拿到别人的 Profile
} verify(providerService, times(10)).chat(any(), any(), any()); // 恰好 10 轮`
`#@Test
void 模型一直要调工具_转满最大轮数强制停() {
when(providerService.chat(any(), any(), any()))
.thenReturn(responseWithToolCall(httpGetCall)); // 每轮都要调工具,永不收敛
String reply = loop.run(session, "查天气", profileWithMaxIterations(10));
verify(providerService, times(10)).chat(any(), any(), any()); // 恰好 10 轮
assertTrue(reply.contains("达到最大轮数"));
}
@Test
void 处理中抛异常_ProfileContext也必须被清掉() {
when(reActLoop.run(any(), any(), any())).thenThrow(new RuntimeException("boom"));
assertThrows(RuntimeException.class, () -> agentService.process(session, "hi"));
assertNull(ProfileContext.current()); // 没清掉,复用此线程的下一个请求会拿到别人的 Profile
} assertTrue(reply.contains("达到最大轮数"));`
`#@Test
void 模型一直要调工具_转满最大轮数强制停() {
when(providerService.chat(any(), any(), any()))
.thenReturn(responseWithToolCall(httpGetCall)); // 每轮都要调工具,永不收敛
String reply = loop.run(session, "查天气", profileWithMaxIterations(10));
verify(providerService, times(10)).chat(any(), any(), any()); // 恰好 10 轮
assertTrue(reply.contains("达到最大轮数"));
}
@Test
void 处理中抛异常_ProfileContext也必须被清掉() {
when(reActLoop.run(any(), any(), any())).thenThrow(new RuntimeException("boom"));
assertThrows(RuntimeException.class, () -> agentService.process(session, "hi"));
assertNull(ProfileContext.current()); // 没清掉,复用此线程的下一个请求会拿到别人的 Profile
}}`
`#@Test
void 模型一直要调工具_转满最大轮数强制停() {
when(providerService.chat(any(), any(), any()))
.thenReturn(responseWithToolCall(httpGetCall)); // 每轮都要调工具,永不收敛
String reply = loop.run(session, "查天气", profileWithMaxIterations(10));
verify(providerService, times(10)).chat(any(), any(), any()); // 恰好 10 轮
assertTrue(reply.contains("达到最大轮数"));
}
@Test
void 处理中抛异常_ProfileContext也必须被清掉() {
when(reActLoop.run(any(), any(), any())).thenThrow(new RuntimeException("boom"));
assertThrows(RuntimeException.class, () -> agentService.process(session, "hi"));
assertNull(ProfileContext.current()); // 没清掉,复用此线程的下一个请求会拿到别人的 Profile
}`
`#@Test
void 模型一直要调工具_转满最大轮数强制停() {
when(providerService.chat(any(), any(), any()))
.thenReturn(responseWithToolCall(httpGetCall)); // 每轮都要调工具,永不收敛
String reply = loop.run(session, "查天气", profileWithMaxIterations(10));
verify(providerService, times(10)).chat(any(), any(), any()); // 恰好 10 轮
assertTrue(reply.contains("达到最大轮数"));
}
@Test
void 处理中抛异常_ProfileContext也必须被清掉() {
when(reActLoop.run(any(), any(), any())).thenThrow(new RuntimeException("boom"));
assertThrows(RuntimeException.class, () -> agentService.process(session, "hi"));
assertNull(ProfileContext.current()); // 没清掉,复用此线程的下一个请求会拿到别人的 Profile
}@Test`
`#@Test
void 模型一直要调工具_转满最大轮数强制停() {
when(providerService.chat(any(), any(), any()))
.thenReturn(responseWithToolCall(httpGetCall)); // 每轮都要调工具,永不收敛
String reply = loop.run(session, "查天气", profileWithMaxIterations(10));
verify(providerService, times(10)).chat(any(), any(), any()); // 恰好 10 轮
assertTrue(reply.contains("达到最大轮数"));
}
@Test
void 处理中抛异常_ProfileContext也必须被清掉() {
when(reActLoop.run(any(), any(), any())).thenThrow(new RuntimeException("boom"));
assertThrows(RuntimeException.class, () -> agentService.process(session, "hi"));
assertNull(ProfileContext.current()); // 没清掉,复用此线程的下一个请求会拿到别人的 Profile
}void 处理中抛异常_ProfileContext也必须被清掉() {`
`#@Test
void 模型一直要调工具_转满最大轮数强制停() {
when(providerService.chat(any(), any(), any()))
.thenReturn(responseWithToolCall(httpGetCall)); // 每轮都要调工具,永不收敛
String reply = loop.run(session, "查天气", profileWithMaxIterations(10));
verify(providerService, times(10)).chat(any(), any(), any()); // 恰好 10 轮
assertTrue(reply.contains("达到最大轮数"));
}
@Test
void 处理中抛异常_ProfileContext也必须被清掉() {
when(reActLoop.run(any(), any(), any())).thenThrow(new RuntimeException("boom"));
assertThrows(RuntimeException.class, () -> agentService.process(session, "hi"));
assertNull(ProfileContext.current()); // 没清掉,复用此线程的下一个请求会拿到别人的 Profile
} when(reActLoop.run(any(), any(), any())).thenThrow(new RuntimeException("boom"));`
`#@Test
void 模型一直要调工具_转满最大轮数强制停() {
when(providerService.chat(any(), any(), any()))
.thenReturn(responseWithToolCall(httpGetCall)); // 每轮都要调工具,永不收敛
String reply = loop.run(session, "查天气", profileWithMaxIterations(10));
verify(providerService, times(10)).chat(any(), any(), any()); // 恰好 10 轮
assertTrue(reply.contains("达到最大轮数"));
}
@Test
void 处理中抛异常_ProfileContext也必须被清掉() {
when(reActLoop.run(any(), any(), any())).thenThrow(new RuntimeException("boom"));
assertThrows(RuntimeException.class, () -> agentService.process(session, "hi"));
assertNull(ProfileContext.current()); // 没清掉,复用此线程的下一个请求会拿到别人的 Profile
} assertThrows(RuntimeException.class, () -> agentService.process(session, "hi"));`
`#@Test
void 模型一直要调工具_转满最大轮数强制停() {
when(providerService.chat(any(), any(), any()))
.thenReturn(responseWithToolCall(httpGetCall)); // 每轮都要调工具,永不收敛
String reply = loop.run(session, "查天气", profileWithMaxIterations(10));
verify(providerService, times(10)).chat(any(), any(), any()); // 恰好 10 轮
assertTrue(reply.contains("达到最大轮数"));
}
@Test
void 处理中抛异常_ProfileContext也必须被清掉() {
when(reActLoop.run(any(), any(), any())).thenThrow(new RuntimeException("boom"));
assertThrows(RuntimeException.class, () -> agentService.process(session, "hi"));
assertNull(ProfileContext.current()); // 没清掉,复用此线程的下一个请求会拿到别人的 Profile
} assertNull(ProfileContext.current()); // 没清掉,复用此线程的下一个请求会拿到别人的 Profile`
`#@Test
void 模型一直要调工具_转满最大轮数强制停() {
when(providerService.chat(any(), any(), any()))
.thenReturn(responseWithToolCall(httpGetCall)); // 每轮都要调工具,永不收敛
String reply = loop.run(session, "查天气", profileWithMaxIterations(10));
verify(providerService, times(10)).chat(any(), any(), any()); // 恰好 10 轮
assertTrue(reply.contains("达到最大轮数"));
}
@Test
void 处理中抛异常_ProfileContext也必须被清掉() {
when(reActLoop.run(any(), any(), any())).thenThrow(new RuntimeException("boom"));
assertThrows(RuntimeException.class, () -> agentService.process(session, "hi"));
assertNull(ProfileContext.current()); // 没清掉,复用此线程的下一个请求会拿到别人的 Profile
}}
-
注意点
- ThreadLocal 泄漏在单请求测试里永远不报错,只在并发复用时串号,所以必须在 harness 里显式钉死
-
人工确认
- "每日天气"Demo 的对话版用真模型跑通:多轮对话里调了
http_get、拿到数据、给出穿搭建议 - 循环是自己实现的,没用框架现成的 Agent 封装(code review 确认,测不出来)
- "每日天气"Demo 的对话版用真模型跑通:多轮对话里调了
面试/考试记忆点
- 先起工程地基再写业务:日志、监控、HTTP、API 规范、代码规范、安全检查用一个 init skill 一次装好
- skill 负责装、constitution 负责钉死、CI 负责强制;Skill 最适合固化通用、可重复的流程
- Provider 是 Agent 和大模型之间的前台,只管挑模型、发起一次调用、记审计,换模型只改配置
- 多 provider 靠显式映射表区分;必须关掉 Spring AI 的自动执行工具,执行权收在 ToolExecutor
- 配置分两层:全局层管"连不连得上",Profile 层管"怎么用",引用不存在的 provider 直接报错
- LLM 调用和工具调用成功失败都要落审计,
llm_calls与tool_invocations都带 success / error_message - 验收 harness 按"碰不碰真实网络"分层,每个设计阶段想到的坑都要有回归测试钉死
- ReAct 是"想一步、做一步、看结果"的循环;循环只做调度,拼上下文、调模型、执行工具都交出去
- ReAct 三个边界:最大轮数防死循环、只留最近 N 轮防上下文撑爆、每轮结果累积回 Session 便于审计
- ProfileContext 用 ThreadLocal 传当前 Profile,finally 必须清理,否则线程复用时串号