工程地基、Provider 与 ReAct 循环:Agent 核心能力的设计、实现与验收

简介:先用一个 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 都行,谁对谁必须写死、看得见
  • 第三步:写 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
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 确认,测不出来)

面试/考试记忆点

  • 先起工程地基再写业务:日志、监控、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 必须清理,否则线程复用时串号
相关推荐
桃西西呀1 小时前
Spring AI Alibaba 之九:我让 Agent 接着上一轮往下聊,它却只还给我一条消息,多轮记忆到底藏在哪
人工智能·spring·llm
10年前端老司机1 小时前
一次业务迭代:聊聊知识库多版本可追溯审核流程怎么做
人工智能·后端·agent
呆萌很1 小时前
长尾分类(Long-tailed Classification)方法汇总
人工智能·分类
for_ever_love__1 小时前
LangChain Agentic RAG:让 Agent 自己决定要不要检索、检索几次
人工智能·golang·langchain·rag·langgraph·agentic·自适应检索
Fxrain1 小时前
[Paper-Reading] CUAs 安全与隐私威胁综述
人工智能·安全·agent·智能体
乱世军军1 小时前
安装最新版本
人工智能
Mickey同学1 小时前
大模型应用的运行原理:Token、上下文、采样、成本与工程边界
人工智能
海边捡石子1 小时前
向量、图谱还是文件:Agent 记忆怎么选
人工智能·llm·agent
tiger从容淡定是人生1 小时前
从 Gemini 的任务示例出发:AI 能否从知识中推导工作流?
java·人工智能·aigc·知识图谱·创业创新·零知识证明