Skip to content

第 2 章:运营对话入口,接入 Spring AI Alibaba

1. 先看业务问题

上一章我们已经确认:产品运营要的不是普通 ChatBot,而是能逐步办事的 Agent 工作台。

但现在系统还有一个最基本的问题:

text
运营输入一句需求后,后端还没有真正调用大模型。

比如运营输入:

text
帮我分析最近 7 天新用户留存下降的原因,并给我一份下周拉活活动方案。

第 1 章接口只能返回一段固定的业务认知说明。它可以帮助我们讲清楚系统边界,但不能生成面向运营的自然语言回复。

所以本章只解决一个问题:

text
让 JDK 17 + Spring Boot 项目通过 Spring AI Alibaba 接入模型,提供第一个运营对话入口。

注意,本章还不让模型查数据库,也不让模型调用 Tool。因为现在还没有权限、参数校验和数据边界,直接让模型“分析留存下降原因”很容易生成没有依据的结论。

2. 这一章为什么要学?

不学这一章,后面的 Tool Calling、MCP、RAG、Skill、Agent Framework 和 Graph Core 都没有入口。

因为真实系统的第一步一定是:

text
运营请求
→ 后端接口
→ 组装 Prompt
→ 调用模型
→ 返回第一版回复

这一章学完后,我们至少能把系统从“固定返回文案”升级成“后端可控地调用大模型”。

但我们也要立刻建立边界:

  • 模型可以生成第一版回复。
  • 模型不能假装已经查过真实数据。
  • 模型不能直接拼 SQL。
  • 模型不能直接保存方案或创建任务。
  • 模型输出必须经过后端包装后再返回给运营端。

本章处在整套课程中的位置:

text
第 1 章:先分清普通 ChatBot 和 Agent 工作台
第 2 章:接入 Spring AI Alibaba,跑通第一个运营对话入口
第 3 章:把运营自然语言解析成结构化任务单
第 4 章:为什么不能让模型直接查数据库

3. 这一章学完能做什么?

学完后,你可以做到:

  1. 在 JDK 17 + Spring Boot 工程中引入 Spring AI Alibaba。
  2. 配置 DashScope API Key、模型和本地兜底模式。
  3. ChatClient 给产品运营提供第一个对话接口。
  4. 在没有 API Key 的情况下仍然能跑通测试和本地接口。
  5. 解释为什么本章只做“第一版回复”,不提前做 Tool、RAG 和 Agent 编排。

本章交付接口:

text
POST /api/chapter-02/chat

4. 前置知识

需要:

  • JDK 17。
  • Spring Boot Controller / Service。
  • Maven dependency management。
  • YAML 配置。
  • REST API 测试。

不需要:

  • Tool Calling。
  • MCP。
  • RAG。
  • Memory。
  • Advisor。
  • Skill。
  • 多 Agent。
  • Graph Core。

这些都不是本章主问题,后面会逐章引入。

5. 本章涉及的核心组件

本章涉及:

  • Spring Boot 3.4.5。
  • Spring AI Alibaba 1.0.0.2。
  • spring-ai-alibaba-starter-dashscope
  • ChatClient
  • DashScope Chat Model。
  • System Prompt。
  • User Prompt。
  • 本地兜底实现。

本章不涉及:

  • Streaming:第 2 阶段后续章节再讲。
  • Structured Output:第 3 章讲。
  • Tool Calling:第 4、5、6 章讲。
  • MCP:第 7、8 章讲。
  • RAG:第 9、10、11 章讲。
  • AdvisorMemory:第 12、13 章讲。
  • SkillAgent FrameworkGraph Core:后续阶段讲。

版本依据:

  • Spring AI Alibaba 官方快速开始文档。
  • Spring AI Alibaba GitHub 仓库。

本章代码固定使用 JDK 17,不使用 JDK 21 特性。

6. 先写一个最小可运行版本

代码位置:

text
example/stage-01-operation-agent-workbench

本章新增调用链:

text
ChapterTwoController
→ OperationChatService
→ LocalOperationChatService 或 DashScopeOperationChatService
→ ChapterTwoChatResponse

设计上有两个实现:

  • LocalOperationChatService:默认启用,不需要 API Key,保证本地和测试稳定。
  • DashScopeOperationChatService:配置 operation.ai.mode=dashscope 后启用,真正调用 ChatClient

这样做是为了避免一个很常见的教学坑:

text
只要没有 API Key,整个项目就启动不了。

真实课程项目不能这样。基础代码必须能被学习者稳定拉起。

6.1 pom.xml

xml
<?xml version="1.0" encoding="UTF-8"?>
<project xmlns="http://maven.apache.org/POM/4.0.0"
         xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
         xsi:schemaLocation="http://maven.apache.org/POM/4.0.0 https://maven.apache.org/xsd/maven-4.0.0.xsd">
    <modelVersion>4.0.0</modelVersion>

    <parent>
        <groupId>org.springframework.boot</groupId>
        <artifactId>spring-boot-starter-parent</artifactId>
        <version>3.4.5</version>
        <relativePath/>
    </parent>

    <groupId>com.zhixing</groupId>
    <artifactId>stage-01-operation-agent-workbench</artifactId>
    <version>0.1.0-SNAPSHOT</version>
    <name>stage-01-operation-agent-workbench</name>
    <description>Stage 01 skeleton for operation agent workbench course.</description>

    <properties>
        <java.version>17</java.version>
        <spring-ai-alibaba.version>1.0.0.2</spring-ai-alibaba.version>
    </properties>

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

    <dependencies>
        <dependency>
            <groupId>org.springframework.boot</groupId>
            <artifactId>spring-boot-starter-web</artifactId>
        </dependency>
        <dependency>
            <groupId>com.alibaba.cloud.ai</groupId>
            <artifactId>spring-ai-alibaba-starter-dashscope</artifactId>
        </dependency>
        <dependency>
            <groupId>org.springframework.boot</groupId>
            <artifactId>spring-boot-starter-test</artifactId>
            <scope>test</scope>
        </dependency>
    </dependencies>
</project>

6.2 application.yml

yaml
server:
  port: 18081

spring:
  application:
    name: stage-01-operation-agent-workbench
  ai:
    model:
      chat: none
    dashscope:
      api-key: ${AI_DASHSCOPE_API_KEY:${DASHSCOPE_API_KEY:}}
      chat:
        options:
          model: qwen-plus

operation:
  ai:
    mode: local

这里默认是 local 模式。

原因很简单:本地学习、CI 测试、文档站构建都不应该强依赖真实模型密钥。

要启用真实模型时,启动参数覆盖:

powershell
$env:AI_DASHSCOPE_API_KEY = "你的 DashScope API Key"
mvn.cmd spring-boot:run "-Dspring-boot.run.arguments=--operation.ai.mode=dashscope --spring.ai.model.chat=dashscope"

6.3 Response DTO

java
package com.zhixing.operationagent.domain;

import java.util.List;

public record ChapterTwoChatResponse(
        String demand,
        String provider,
        String role,
        String content,
        List<String> nextSteps
) {
}

不要直接把模型原始返回丢给前端。

我们至少要包装出:

  • 原始运营需求。
  • 使用的提供方。
  • 当前 Agent 角色。
  • 回复内容。
  • 下一步学习或系统演进方向。

这就是后端系统和直接调模型 API 的第一个区别:后端要给前端稳定的数据结构。

6.4 Service 接口

java
package com.zhixing.operationagent.application;

import com.zhixing.operationagent.api.OperationDemandRequest;
import com.zhixing.operationagent.domain.ChapterTwoChatResponse;

public interface OperationChatService {

    ChapterTwoChatResponse chat(OperationDemandRequest request);
}

这里先抽一个接口,不是为了炫技,而是为了隔离两个实现:

  • 本地可运行实现。
  • 真实模型实现。

后面引入 Tool、RAG、Agent 时,也会继续从这个入口演进。

6.5 LocalOperationChatService

java
package com.zhixing.operationagent.application;

import com.zhixing.operationagent.api.OperationDemandRequest;
import com.zhixing.operationagent.domain.ChapterTwoChatResponse;
import org.springframework.boot.autoconfigure.condition.ConditionalOnMissingBean;
import org.springframework.stereotype.Service;

import java.util.List;

@Service
@ConditionalOnMissingBean(OperationChatService.class)
public class LocalOperationChatService implements OperationChatService {

    @Override
    public ChapterTwoChatResponse chat(OperationDemandRequest request) {
        String demand = normalizeDemand(request);

        return new ChapterTwoChatResponse(
                demand,
                "local",
                "运营助理",
                """
                这是第一版运营回复:我会先围绕你的需求拆出分析目标、时间范围和预期产出。
                但当前还没有查询真实业务数据,所以只能给出处理思路,不能给出确定结论。
                下一章会把这句话解析成结构化任务单,再继续引出安全 Tool Calling。
                """.trim(),
                List.of("第 3 章解析结构化任务单", "第 4 章引出安全 Tool Calling")
        );
    }

    private String normalizeDemand(OperationDemandRequest request) {
        if (request == null || request.demand() == null || request.demand().isBlank()) {
            return "分析最近 7 天新用户留存下降原因,并生成拉活活动方案";
        }

        return request.demand().trim();
    }
}

这个类不是生产方案,它只是教学兜底。

作用是:

  • 没有 API Key 也能跑。
  • 测试不依赖外部网络。
  • 让学习者先看懂接口形态。
  • 真实模型失败时,后续可以演进为降级策略。

6.6 DashScopeOperationChatService

java
package com.zhixing.operationagent.application;

import com.zhixing.operationagent.api.OperationDemandRequest;
import com.zhixing.operationagent.domain.ChapterTwoChatResponse;
import org.springframework.ai.chat.client.ChatClient;
import org.springframework.boot.autoconfigure.condition.ConditionalOnProperty;
import org.springframework.stereotype.Service;

import java.util.List;

@Service
@ConditionalOnProperty(name = "operation.ai.mode", havingValue = "dashscope")
public class DashScopeOperationChatService implements OperationChatService {

    private static final String SYSTEM_PROMPT = """
            你是面向产品运营的智能运营 Agent 工作台中的运营助理。
            你现在只负责给出第一版运营回复,不要假装已经查询数据库、知识库或用户反馈。
            如果缺少真实数据,要明确提醒运营:当前回复只是分析思路,不是最终结论。
            回复要面向产品运营人员,直接、具体、可继续追问。
            """;

    private final ChatClient chatClient;

    public DashScopeOperationChatService(ChatClient.Builder chatClientBuilder) {
        this.chatClient = chatClientBuilder
                .defaultSystem(SYSTEM_PROMPT)
                .build();
    }

    @Override
    public ChapterTwoChatResponse chat(OperationDemandRequest request) {
        String demand = normalizeDemand(request);
        String content = chatClient.prompt()
                .user(demand)
                .call()
                .content();

        return new ChapterTwoChatResponse(
                demand,
                "dashscope",
                "运营助理",
                content,
                List.of("第 3 章解析结构化任务单", "第 4 章引出安全 Tool Calling")
        );
    }

    private String normalizeDemand(OperationDemandRequest request) {
        if (request == null || request.demand() == null || request.demand().isBlank()) {
            return "分析最近 7 天新用户留存下降原因,并生成拉活活动方案";
        }

        return request.demand().trim();
    }
}

这段代码是本章真正接入 Spring AI Alibaba 的地方。

核心是:

java
chatClient.prompt()
        .user(demand)
        .call()
        .content();

SYSTEM_PROMPT 的重点不是让模型“像人一样聊天”,而是约束它不要冒充已经查过真实业务数据。

6.7 Controller

java
package com.zhixing.operationagent.api;

import com.zhixing.operationagent.application.OperationChatService;
import com.zhixing.operationagent.domain.ChapterTwoChatResponse;
import org.springframework.web.bind.annotation.PostMapping;
import org.springframework.web.bind.annotation.RequestBody;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RestController;

@RestController
@RequestMapping(value = "/api/chapter-02", produces = "application/json;charset=UTF-8")
public class ChapterTwoController {

    private final OperationChatService operationChatService;

    public ChapterTwoController(OperationChatService operationChatService) {
        this.operationChatService = operationChatService;
    }

    @PostMapping("/chat")
    public ChapterTwoChatResponse chat(@RequestBody OperationDemandRequest request) {
        return operationChatService.chat(request);
    }
}

Controller 只做入口转发,不拼 Prompt,不处理模型细节。

真实项目里 Controller 层越薄,后面越容易加权限、日志、限流和审计。

6.8 测试

java
@WebMvcTest(ChapterTwoController.class)
class ChapterTwoControllerTest {

    @Autowired
    private MockMvc mockMvc;

    @MockitoBean
    private OperationChatService operationChatService;

    @Test
    void chatReturnsOperationAssistantResponse() throws Exception {
        when(operationChatService.chat(any(OperationDemandRequest.class))).thenReturn(
                new ChapterTwoChatResponse(
                        "帮我分析最近 7 天新用户留存下降的原因,并给我一份下周拉活活动方案。",
                        "local",
                        "运营助理",
                        "我会先澄清运营目标,再给出第一版分析,但当前还没有接入真实数据 Tool。",
                        List.of("第 3 章解析结构化任务单", "第 4 章引出安全 Tool Calling")
                )
        );

        mockMvc.perform(post("/api/chapter-02/chat")
                        .contentType(APPLICATION_JSON)
                        .accept(APPLICATION_JSON)
                        .content("""
                                {
                                  "demand": "帮我分析最近 7 天新用户留存下降的原因,并给我一份下周拉活活动方案。"
                                }
                                """))
                .andExpect(status().isOk())
                .andExpect(content().contentType("application/json;charset=UTF-8"))
                .andExpect(jsonPath("$.provider").value("local"))
                .andExpect(jsonPath("$.role").value("运营助理"))
                .andExpect(jsonPath("$.nextSteps[0]").value("第 3 章解析结构化任务单"));
    }
}

7. 运行一下,看现象

7.1 本地模式启动

powershell
Set-Location D:\codex_space\personal-docs-site\example\stage-01-operation-agent-workbench
mvn.cmd spring-boot:run

请求:

powershell
$body = @{
  demand = "帮我分析最近 7 天新用户留存下降的原因,并给我一份下周拉活活动方案。"
} | ConvertTo-Json

Invoke-RestMethod `
  -Uri http://localhost:18081/api/chapter-02/chat `
  -Method Post `
  -ContentType "application/json; charset=utf-8" `
  -Body $body

返回示例:

json
{
  "demand": "帮我分析最近 7 天新用户留存下降的原因,并给我一份下周拉活活动方案。",
  "provider": "local",
  "role": "运营助理",
  "content": "这是第一版运营回复:我会先围绕你的需求拆出分析目标、时间范围和预期产出。\n但当前还没有查询真实业务数据,所以只能给出处理思路,不能给出确定结论。\n下一章会把这句话解析成结构化任务单,再继续引出安全 Tool Calling。",
  "nextSteps": [
    "第 3 章解析结构化任务单",
    "第 4 章引出安全 Tool Calling"
  ]
}

成功现象:

  • HTTP 状态码是 200
  • providerlocal
  • 即使没有 API Key,也能返回稳定结果。
  • 返回内容明确提醒“还没有查询真实业务数据”。

失败现象:

  • 端口被占用:修改 server.port 或关闭占用进程。
  • 请求体不是 JSON:会返回 400
  • 中文乱码:检查 Content-Type 是否带 charset=utf-8

7.2 DashScope 模式启动

powershell
$env:AI_DASHSCOPE_API_KEY = "你的 DashScope API Key"
mvn.cmd spring-boot:run "-Dspring-boot.run.arguments=--operation.ai.mode=dashscope --spring.ai.model.chat=dashscope"

成功现象:

  • provider 返回 dashscope
  • content 由真实模型生成。
  • 回复会遵守 System Prompt,不会假装已经查过数据库。

失败现象:

  • API Key 为空:模型调用失败。
  • 模型名不可用:DashScope 返回模型相关错误。
  • 网络超时:请求等待时间变长或失败。

关键断点位置:

  • ChapterTwoController#chat
  • DashScopeOperationChatService#chat
  • ChatClient.prompt().user(demand).call().content()

8. 代码讲解

调用链如下:

text
运营前端发送需求

ChapterTwoController 接收 HTTP 请求

OperationChatService 屏蔽本地模式和 DashScope 模式差异

LocalOperationChatService 返回稳定教学响应

DashScopeOperationChatService 通过 ChatClient 调用模型

ChapterTwoChatResponse 包装统一响应

返回给运营前端

Controller 做了什么?

  • 暴露 /api/chapter-02/chat
  • 接收 OperationDemandRequest
  • 调用应用服务。
  • 返回结构化响应。

Service 做了什么?

  • 归一化运营需求。
  • 选择本地兜底或真实模型。
  • 包装返回结果。

ChatClient 做了什么?

  • 把 System Prompt 和 User Prompt 组合成一次模型请求。
  • 调用底层 ChatModel。
  • 返回模型生成内容。

模型返回后怎么处理?

  • 本章只取 content()
  • 再包装到 ChapterTwoChatResponse
  • 不解析 JSON,不调用工具,不保存状态。

这也是本章边界。

9. 为什么要这样设计?

为什么用 ChatClient?

因为本章要解决的是最基础的“后端调用模型”问题。

ChatClient 比直接操作底层 ChatModel 更适合业务代码:

  • 写法更接近一次对话。
  • 可以设置默认 System Prompt。
  • 后续更容易接 Advisor、Memory、Tool 和结构化输出。

为什么不直接写死 DashScope 调用?

因为课程项目要能被稳定运行。

真实学习场景里,很多人第一次运行项目时没有 API Key。如果项目直接启动失败,学习会卡在环境问题上。

所以默认用 local 模式,真实模型通过配置打开。

为什么不让模型直接分析留存下降原因?

因为它还没查真实数据。

现在如果模型说:

text
留存下降主要因为渠道质量下降。

这句话没有证据。

真实项目里,任何涉及数据结论的内容,都必须来自 Tool、MCP 或 RAG 的可追溯输入。

为什么不在本章做 Tool Calling?

因为 Tool 不是“顺便加一个方法”。

Tool 需要考虑:

  • 参数校验。
  • 权限控制。
  • 数据脱敏。
  • 调用失败兜底。
  • 防止模型乱调。

这些是第 4 章之后的主问题。

10. 画出流程图

text
运营输入自然语言需求

ChapterTwoController

OperationChatService

判断 operation.ai.mode

local 模式:返回稳定教学响应

dashscope 模式:ChatClient 调用 DashScope

包装 ChapterTwoChatResponse

返回运营前端

本章流程还很短。

这是有意的。

真正的 Agent 工作台会在后面逐步变成:

text
运营输入

结构化任务单

Tool / MCP 查询数据

RAG 检索资料

Agent 生成方案

Graph Core 编排确认和保存

11. 真实项目怎么落地?

真实项目里,本章代码要继续补这些工程能力:

  • 包结构:api 放接口,application 放用例服务,domain 放业务返回对象,后续新增 infrastructure 放模型和外部系统适配。
  • 模型配置:API Key 只能走环境变量、密钥管理或配置中心,不能提交到 Git。
  • Prompt 管理:System Prompt 后续要版本化,不能散落在代码里。
  • 日志记录:记录请求 ID、用户 ID、模型、耗时、是否降级,不记录完整敏感内容。
  • 超时控制:模型调用必须有超时和失败兜底。
  • 权限控制:运营人员只能访问自己有权限的数据范围。
  • 成本控制:记录 token、模型、调用次数,避免无感知烧成本。
  • 输出评估:不能只看模型“写得像不像”,还要评估依据、可执行性、风险提醒。
  • 降级策略:模型失败时返回可解释错误,而不是 500 砸给前端。

本章代码只是“教学可运行版本”。

工程可用版本至少要继续引入:

  • 参数校验。
  • 异常处理。
  • 超时配置。
  • 日志追踪。
  • Prompt 模板管理。
  • 模型调用指标。

生产级版本还要继续引入:

  • 租户隔离。
  • 权限系统。
  • 审计日志。
  • 敏感词过滤。
  • Human in the Loop。
  • 任务状态持久化。

12. 常见坑

坑 1:没有 API Key,项目启动失败

  • 现象:本地一启动就报模型配置错误。
  • 原因:真实模型自动配置被默认启用。
  • 解决办法:默认 spring.ai.model.chat=none,需要真实模型时再显式打开。
  • 如何提前避免:教学项目和 CI 默认不要依赖外部密钥。

坑 2:模型假装查过数据

  • 现象:模型回复“留存下降是因为渠道质量下降”,但系统没有查任何数据。
  • 原因:Prompt 没有限制模型边界。
  • 解决办法:System Prompt 明确说明当前只能给分析思路,不能冒充数据结论。
  • 如何提前避免:所有数据结论都必须来自 Tool、MCP 或 RAG。

坑 3:Controller 里直接拼模型调用

  • 现象:Controller 很快变成几十行甚至上百行。
  • 原因:入口层混入 Prompt、模型调用、兜底和返回包装。
  • 解决办法:Controller 只转发,模型调用放到应用服务。
  • 如何提前避免:保持 apiapplicationdomain 分层。

坑 4:把本地兜底当成生产方案

  • 现象:上线后仍然返回固定模板,运营以为是模型在分析。
  • 原因:没有区分教学 Demo、工程可用版本和生产版本。
  • 解决办法:响应里明确返回 provider
  • 如何提前避免:所有降级结果都要可识别、可观测。

坑 5:把本章当成完整 Agent

  • 现象:接入 ChatClient 后就开始承诺能查数据、生成方案、保存任务。
  • 原因:混淆了 ChatClient 和 Agent 工作台。
  • 解决办法:本章只做对话入口,后续逐章补齐 Tool、MCP、RAG、Skill、Agent 和 Graph。
  • 如何提前避免:每章只引入一个核心能力。

13. 本章小结

这一章解决了:

text
运营 Agent 工作台如何接入第一个模型对话入口。

学到的技术点:

  • Spring AI Alibaba 依赖接入。
  • DashScope API Key 配置。
  • ChatClient 最小调用。
  • System Prompt 和 User Prompt 的边界。
  • 本地兜底模式。
  • 统一响应 DTO。

最关键代码:

java
chatClient.prompt()
        .user(demand)
        .call()
        .content();

真实项目最重要的设计点:

text
模型可以生成回复,但不能越权替后端完成未授权动作。

下一章为什么继续升级?

因为现在模型拿到的是一整段自然语言。后端还不知道:

  • 用户要分析什么指标。
  • 时间范围是什么。
  • 输出格式是什么。
  • 是否需要活动方案。
  • 是否需要文案。

所以下一章要把运营需求解析成结构化任务单。

14. 课后练习

基础练习

把默认运营需求改成:

text
分析最近 14 天老用户活跃下降原因,并给出召回建议

要求:

  • 修改默认值。
  • 跑通 /api/chapter-02/chat
  • 确认返回的 demand 已变化。

改造练习

ChapterTwoChatResponse 增加一个字段:

text
warnings

用于返回:

text
当前回复没有查询真实业务数据,不能作为最终结论。

要求:

  • 先写测试。
  • 再改代码。
  • 确认本地模式和 Controller 测试都通过。

综合练习

把 System Prompt 改造成更适合“活动运营”的版本。

要求模型输出时必须包含:

  • 当前能做什么。
  • 当前不能做什么。
  • 需要运营补充哪些信息。
  • 下一步建议。

注意:

不要让模型假装已经查询数据。

Built with VitePress. Deployed on Cloudflare Pages.