谁说 Java 老项目不能"玩" AI ?

保姆级教程:用原生 HTTP 接入阿里云百炼,实现 RAG + 会话持久化 + Tool Calls

不用任何 AI SDK,纯手写 HTTP 请求,从零搭建一个能对话、能检索、能调用后端函数的智能系统。后面会介绍一些AI接入的框架(Langchain4J、SpringAI等),那些就是固定模板使用就行,原生HTTP随意性比较大。

Gitee代码来了,我已经在本地可以正常跑通的:https://gitee.com/sun-guo-qiang/student_management_http_ai.git


写在前面

最近接了个需求(我自己给自己加的,为了验证能否实现):给现有的学生管理系统加个 AI 助手。学生问"我数学考了多少分",系统能直接回答,不用自己翻页面。

一开始想着直接用官方 SDK 省事,但后来一想,SDK 封装太多,出了问题不好排查,而且很多公司出于合规考虑不让随便引入第三方依赖。于是决定用原生 HTTP 请求直接调百炼 API。

折腾了几天,跑通了三个核心功能:

  • RAG 检索增强:把 FAQ 文档转成向量,用户提问时先搜相关知识再回答
  • 会话 & 消息持久化:聊天记录存 MySQL,刷新页面还能接着聊
  • Tool Calls 工具调用:AI 能自动调用后端函数,比如查成绩、做计算

这篇文章把整个实现过程从头到尾捋一遍,代码都是跑通的,可以直接拿去用。


一、环境准备

1.1 开通阿里云百炼

  1. 登录 阿里云百炼控制台
  2. 开通百炼服务,获取 API KeyWorkspace ID
  3. 确认你需要的模型(我用的是 qwen3.7-plus)和向量化模型(text-embedding-v4

1.2 创建 Spring Boot 项目

用 IDEA 或者 Spring Initializr 创建一个 Spring Boot 项目,引入以下依赖:

xml 复制代码
<dependencies>
    <!-- Spring Boot Web -->
    <dependency>
        <groupId>org.springframework.boot</groupId>
        <artifactId>spring-boot-starter-web</artifactId>
    </dependency>

    <!-- OkHttp(HTTP 客户端) -->
    <dependency>
        <groupId>com.squareup.okhttp3</groupId>
        <artifactId>okhttp</artifactId>
        <version>4.12.0</version>
    </dependency>

    <!-- Jackson(JSON 序列化) -->
    <dependency>
        <groupId>com.fasterxml.jackson.core</groupId>
        <artifactId>jackson-databind</artifactId>
    </dependency>

    <!-- MyBatis Plus(数据库操作) -->
    <dependency>
        <groupId>com.baomidou</groupId>
        <artifactId>mybatis-plus-boot-starter</artifactId>
        <version>3.5.5</version>
    </dependency>

    <!-- MySQL 驱动 -->
    <dependency>
        <groupId>com.mysql</groupId>
        <artifactId>mysql-connector-j</artifactId>
    </dependency>

    <!-- Lombok(减少样板代码) -->
    <dependency>
        <groupId>org.projectlombok</groupId>
        <artifactId>lombok</artifactId>
        <optional>true</optional>
    </dependency>
</dependencies>

1.3 配置文件

application.yml 里配置好数据库和百炼的参数:

yaml 复制代码
server:
  port: 8081

spring:
  datasource:
    url: jdbc:mysql://localhost:3306/student_management?useSSL=false&serverTimezone=Asia/Shanghai
    username: root
    password: 123456
    driver-class-name: com.mysql.cj.jdbc.Driver

mybatis-plus:
  mapper-locations: classpath:mapper/*.xml
  configuration:
    log-impl: org.apache.ibatis.logging.stdout.StdOutImpl

bailian:
  api:
    api-key: sk-ws-你的APIKey          # 替换成你自己的
    workspace-id: ws-你的WorkspaceId    # 替换成你自己的
    model: qwen3.7-plus
    embedding-model: text-embedding-v4
  chat:
    max-history-messages: 10           # 最多保留10条历史消息

二、用 OkHttp 调通第一个百炼 API

这是最基础的一步------不依赖任何 SDK,直接发 HTTP 请求调百炼

2.1 理解百炼 API 的调用方式

百炼的 API 是 OpenAI 兼容 的,也就是说请求格式和 OpenAI 的 /v1/chat/completions 完全一样。

  • 请求地址https://{workspaceId}.cn-beijing.maas.aliyuncs.com/compatible-mode/v1/chat/completions
  • 认证方式 :Header 里传 Authorization: Bearer {apiKey}
  • 请求方法:POST
  • Content-Typeapplication/json

2.2 构造请求体

百炼的 Chat Completions API 接收一个 JSON 对象,核心字段如下:

json 复制代码
{
  "model": "qwen3.7-plus",
  "messages": [
    {"role": "system", "content": "你是一个助手"},
    {"role": "user", "content": "你好"}
  ],
  "stream": false
}

在 Java 里,我们用 DTO 类来映射这个 JSON:

java 复制代码
// dto/bailian/ChatRequest.java
@Data
public class ChatRequest {

    private String model;                    // 模型名称
    private List<Message> messages;          // 对话消息列表
    private Boolean stream = false;          // 是否流式输出

    // 消息内部类
    @Data
    public static class Message {
        private String role;                 // user / assistant / system
        private String content;              // 消息内容

        // 静态工厂方法,方便创建
        public static Message user(String content) {
            Message m = new Message();
            m.setRole("user");
            m.setContent(content);
            return m;
        }

        public static Message system(String content) {
            Message m = new Message();
            m.setRole("system");
            m.setContent(content);
            return m;
        }

        public static Message assistant(String content) {
            Message m = new Message();
            m.setRole("assistant");
            m.setContent(content);
            return m;
        }
    }
}

2.3 构造响应体

API 返回的 JSON 长这样:

json 复制代码
{
  "id": "chatcmpl-xxx",
  "model": "qwen3.7-plus",
  "choices": [
    {
      "index": 0,
      "message": {"role": "assistant", "content": "你好!有什么可以帮你的?"},
      "finish_reason": "stop"
    }
  ],
  "usage": {
    "prompt_tokens": 20,
    "completion_tokens": 15,
    "total_tokens": 35
  }
}

对应的 Java DTO:

java 复制代码
// dto/bailian/ChatResponse.java
@Data
@JsonIgnoreProperties(ignoreUnknown = true)   // 忽略未知字段,提高兼容性
public class ChatResponse {

    private String id;
    private String model;
    private List<Choice> choices;
    private Usage usage;

    @Data
    @JsonIgnoreProperties(ignoreUnknown = true)
    public static class Choice {
        private Integer index;
        private Message message;
        @JsonProperty("finish_reason")
        private String finishReason;
    }

    @Data
    @JsonIgnoreProperties(ignoreUnknown = true)
    public static class Message {
        private String role;
        private String content;
    }

    @Data
    public static class Usage {
        @JsonProperty("prompt_tokens")
        private Integer promptTokens;
        @JsonProperty("completion_tokens")
        private Integer completionTokens;
        @JsonProperty("total_tokens")
        private Integer totalTokens;
    }
}

2.4 用 OkHttp 发请求

重头戏来了。BailianChatServiceImpl.java 是真正发 HTTP 请求的地方:

java 复制代码
// service/impl/BailianChatServiceImpl.java
@Slf4j
@Service
public class BailianChatServiceImpl {

    @Value("${bailian.api.api-key}")
    private String apiKey;
    @Value("${bailian.api.workspace-id}")
    private String workspaceId;
    @Value("${bailian.api.model:qwen-plus}")
    private String model;

    private OkHttpClient httpClient;
    private ObjectMapper objectMapper;

    // 百炼 API 地址模板
    private static final String BASE_URL =
            "https://%s.cn-beijing.maas.aliyuncs.com/compatible-mode/v1/chat/completions";

    @PostConstruct
    public void init() {
        // 初始化 OkHttp 客户端,设置超时时间
        this.httpClient = new OkHttpClient.Builder()
                .connectTimeout(30, TimeUnit.SECONDS)
                .readTimeout(60, TimeUnit.SECONDS)
                .writeTimeout(30, TimeUnit.SECONDS)
                .build();
        // 初始化 ObjectMapper,忽略未知字段
        this.objectMapper = new ObjectMapper()
                .configure(DeserializationFeature.FAIL_ON_UNKNOWN_PROPERTIES, false);
    }

    /**
     * 最基础的对话方法
     */
    public String chat(String userMessage) {
        try {
            // 1. 构造消息列表
            List<ChatRequest.Message> messages = new ArrayList<>();
            messages.add(ChatRequest.Message.system("你是一个智能助手"));
            messages.add(ChatRequest.Message.user(userMessage));

            // 2. 构造请求体
            ChatRequest request = new ChatRequest();
            request.setModel(model);
            request.setMessages(messages);
            request.setStream(false);

            // 3. 序列化请求体为 JSON
            String jsonBody = objectMapper.writeValueAsString(request);

            // 4. 拼接 URL(把 workspaceId 替换到子域名)
            String url = String.format(BASE_URL, workspaceId);

            // 5. 构造 OkHttp 请求
            Request httpRequest = new Request.Builder()
                    .url(url)
                    .post(RequestBody.create(jsonBody,
                            MediaType.parse("application/json")))
                    .addHeader("Authorization", "Bearer " + apiKey)
                    .addHeader("Content-Type", "application/json")
                    .build();

            // 6. 发送请求
            try (Response response = httpClient.newCall(httpRequest).execute()) {
                String responseBody = response.body().string();

                if (!response.isSuccessful()) {
                    log.error("调用失败, code={}, body={}", response.code(), responseBody);
                    return "AI服务暂时不可用";
                }

                // 7. 解析响应
                ChatResponse chatResponse = objectMapper.readValue(
                        responseBody, ChatResponse.class);

                if (chatResponse.getChoices() == null || chatResponse.getChoices().isEmpty()) {
                    return "未获取到有效回复";
                }

                // 8. 提取 AI 回复内容
                return chatResponse.getChoices().get(0).getMessage().getContent();
            }
        } catch (IOException e) {
            log.error("调用异常", e);
            return "AI服务异常: " + e.getMessage();
        }
    }
}

关键点说明:

  • @PostConstruct 里的 init() 方法在 Bean 初始化时执行,只创建一次 OkHttp 客户端和 ObjectMapper,避免重复创建
  • RequestBody.create(jsonBody, MediaType.parse("application/json")) 是 OkHttp 发送 POST 请求的标准写法
  • try (Response response = ...) 用了 try-with-resources,确保 response 被正确关闭,不会泄漏连接
  • @JsonIgnoreProperties(ignoreUnknown = true) 很重要,因为百炼返回的字段可能比我们的 DTO 多,不加这个会报错

2.5 写个 Controller 测试一下

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

    @Autowired
    private BailianChatServiceImpl chatService;

    @PostMapping("/simple")
    public Map<String, Object> simpleChat(@RequestBody Map<String, String> req) {
        String reply = chatService.chat(req.get("message"));
        Map<String, Object> result = new HashMap<>();
        result.put("reply", reply);
        return result;
    }
}

启动项目,用 Postman 或者 curl 测试:

bash 复制代码
curl -X POST http://localhost:8081/api/chat/simple \
  -H "Content-Type: application/json" \
  -d '{"message": "你好,请介绍一下你自己"}'

如果一切正常,你会收到 AI 的回复。


三、实现多轮对话(带历史记忆)

上面的例子只能做单轮对话,每次都是全新的对话,AI 不记得之前说过什么。

3.1 多轮对话的原理

多轮对话的核心是:把历史消息也发给 AI

比如用户先问"你好",AI 回答"你好!有什么可以帮你的?",然后用户又问"我数学考了多少分"。

第二次请求时,messages 应该是这样的:

json 复制代码
{
  "messages": [
    {"role": "user", "content": "你好"},
    {"role": "assistant", "content": "你好!有什么可以帮你的?"},
    {"role": "user", "content": "我数学考了多少分"}
  ]
}

这样 AI 就知道上下文了。

3.2 用内存存历史消息

最简单的方案是用 ConcurrentHashMap 存每个用户的对话历史:

java 复制代码
// service/impl/ConversationServiceImpl.java
@Slf4j
@Service
public class ConversationServiceImpl {

    @Value("${bailian.chat.max-history-messages:10}")
    private int maxHistoryMessages;

    // key=userId, value=历史消息列表
    private final Map<Integer, List<ChatRequest.Message>> store = new ConcurrentHashMap<>();
    private final AtomicInteger idGen = new AtomicInteger(1);

    /**
     * 获取用户的历史消息
     */
    public List<ChatRequest.Message> getHistory(Integer userId) {
        if (userId == null) return Collections.emptyList();
        List<ChatRequest.Message> history = store.get(userId);
        return history == null ? Collections.emptyList() : new ArrayList<>(history);
    }

    /**
     * 添加消息到历史
     */
    public void addMessage(Integer userId, String role, String content) {
        if (userId == null || content == null || content.trim().isEmpty()) return;

        List<ChatRequest.Message> history = store.computeIfAbsent(
                userId, k -> new ArrayList<>());
        ChatRequest.Message msg = new ChatRequest.Message();
        msg.setRole(role);
        msg.setContent(content);
        history.add(msg);

        // 超过最大数量时,截断旧消息
        if (history.size() > maxHistoryMessages) {
            List<ChatRequest.Message> newHistory = new ArrayList<>(
                    history.subList(history.size() - maxHistoryMessages, history.size()));
            store.put(userId, newHistory);
        }
    }

    /**
     * 清除用户历史
     */
    public void clearHistory(Integer userId) {
        if (userId != null) store.remove(userId);
    }

    /**
     * 生成新用户 ID
     */
    public Integer generateUserId() {
        return idGen.getAndIncrement();
    }
}

3.3 改造 ChatService,支持多轮

java 复制代码
// service/impl/BailianChatServiceImpl.java(改造后)
@Autowired
private ConversationServiceImpl conversationService;

public String chatWithMemory(Integer userId, String userMessage) {
    // 1. 获取历史消息
    List<ChatRequest.Message> messages = conversationService.getHistory(userId);

    // 2. 添加 system 消息(可选)
    if (messages.isEmpty()) {
        messages.add(0, ChatRequest.Message.system("你是一个智能助手"));
    }

    // 3. 添加当前用户消息
    messages.add(ChatRequest.Message.user(userMessage));

    // 4. 调用 API
    String reply = doChat(messages);

    // 5. 保存用户消息和 AI 回复到历史
    conversationService.addMessage(userId, "user", userMessage);
    conversationService.addMessage(userId, "assistant", reply);

    return reply;
}

这样用户就能体验到"有记忆"的对话了。不过内存存储有个问题:服务重启后数据全丢


四、会话 & 消息持久化到 MySQL

4.1 建表

先建两张表,一张存会话,一张存消息:

sql 复制代码
-- 会话表
CREATE TABLE chat_session (
    id BIGINT AUTO_INCREMENT PRIMARY KEY,
    session_id VARCHAR(64) NOT NULL UNIQUE,
    user_id INT NOT NULL,
    title VARCHAR(255),
    created_at DATETIME DEFAULT CURRENT_TIMESTAMP,
    updated_at DATETIME DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
    deleted TINYINT DEFAULT 0
);

-- 消息表
CREATE TABLE chat_message (
    id BIGINT AUTO_INCREMENT PRIMARY KEY,
    session_id VARCHAR(64) NOT NULL,
    role VARCHAR(20) NOT NULL,          -- user / assistant
    content TEXT,
    tool_calls TEXT,                    -- 工具调用 JSON(后面会用到)
    created_at DATETIME DEFAULT CURRENT_TIMESTAMP,
    INDEX idx_session_id (session_id)
);

4.2 实体类

java 复制代码
// entity/ChatSession.java
@Data
@TableName("chat_session")
public class ChatSession {
    @TableId(type = IdType.AUTO)
    private Long id;
    private String sessionId;
    private Integer userId;
    private String title;
    @TableField(fill = FieldFill.INSERT)
    private LocalDateTime createdAt;
    @TableField(fill = FieldFill.INSERT_UPDATE)
    private LocalDateTime updatedAt;
    @TableLogic
    private Integer deleted;
}

// entity/ChatMessage.java
@Data
@TableName("chat_message")
public class ChatMessage {
    @TableId(type = IdType.AUTO)
    private Long id;
    private String sessionId;
    private String role;
    private String content;
    private String toolCalls;
    @TableField(fill = FieldFill.INSERT)
    private LocalDateTime createdAt;
}

4.3 Mapper 接口

java 复制代码
// mapper/ChatSessionMapper.java
@Mapper
public interface ChatSessionMapper extends BaseMapper<ChatSession> {
    List<ChatSession> selectByUserIdOrderByUpdated(@Param("userId") Integer userId);
}

// mapper/ChatMessageMapper.java
@Mapper
public interface ChatMessageMapper extends BaseMapper<ChatMessage> {
    List<ChatMessage> selectBySessionIdOrderByTime(@Param("sessionId") String sessionId);
}

对应的 XML:

xml 复制代码
<!-- mapper/ChatSessionMapper.xml -->
<mapper namespace="com.sun.student_management_http_ai.mapper.ChatSessionMapper">
    <select id="selectByUserIdOrderByUpdated" resultType="ChatSession">
        SELECT * FROM chat_session
        WHERE user_id = #{userId} AND deleted = 0
        ORDER BY updated_at DESC
    </select>
</mapper>

<!-- mapper/ChatMessageMapper.xml -->
<mapper namespace="com.sun.student_management_http_ai.mapper.ChatMessageMapper">
    <select id="selectBySessionIdOrderByTime" resultType="ChatMessage">
        SELECT * FROM chat_message
        WHERE session_id = #{sessionId}
        ORDER BY created_at ASC
    </select>
</mapper>

4.4 持久化服务

java 复制代码
// service/impl/ChatPersistenceServiceImpl.java
@Service
@RequiredArgsConstructor
public class ChatPersistenceServiceImpl {

    private final ChatSessionMapper sessionMapper;
    private final ChatMessageMapper messageMapper;

    /**
     * 创建新会话
     */
    public String createSession(Integer userId, String firstMessage) {
        String sessionId = UUID.randomUUID().toString();
        ChatSession session = new ChatSession();
        session.setSessionId(sessionId);
        session.setUserId(userId);
        // 首条消息截取前50字作为标题
        if (firstMessage != null && firstMessage.length() > 50) {
            session.setTitle(firstMessage.substring(0, 50));
        } else {
            session.setTitle(firstMessage);
        }
        sessionMapper.insert(session);
        return sessionId;
    }

    /**
     * 保存消息
     */
    public void saveMessage(String sessionId, String role, String content,
                            String toolCallsJson) {
        ChatMessage msg = new ChatMessage();
        msg.setSessionId(sessionId);
        msg.setRole(role);
        msg.setContent(content);
        msg.setToolCalls(toolCallsJson);
        msg.setCreatedAt(LocalDateTime.now());
        messageMapper.insert(msg);

        // 更新会话的 updated_at
        LambdaQueryWrapper<ChatSession> wrapper = new LambdaQueryWrapper<>();
        wrapper.eq(ChatSession::getSessionId, sessionId);
        ChatSession session = sessionMapper.selectOne(wrapper);
        if (session != null) {
            session.setUpdatedAt(LocalDateTime.now());
            sessionMapper.updateById(session);
        }
    }

    /**
     * 获取会话的所有消息(按时间升序)
     */
    public List<ChatMessage> getSessionMessages(String sessionId) {
        return messageMapper.selectBySessionIdOrderByTime(sessionId);
    }

    /**
     * 获取 LLM 格式的历史消息(只取最近 maxHistory 条)
     */
    public List<ChatRequest.Message> getHistoryForLLM(String sessionId, int maxHistory) {
        List<ChatMessage> messages = getSessionMessages(sessionId);
        List<ChatRequest.Message> history = new ArrayList<>();
        int count = 0;

        // 从后往前取,保证取到的是最近的消息
        for (int i = messages.size() - 1; i >= 0; i--) {
            ChatMessage cm = messages.get(i);
            if ("user".equals(cm.getRole()) || "assistant".equals(cm.getRole())) {
                if (count >= maxHistory) break;
                ChatRequest.Message m = new ChatRequest.Message();
                m.setRole(cm.getRole());
                m.setContent(cm.getContent());
                history.add(m);
                count++;
            }
        }
        Collections.reverse(history);  // 恢复时间顺序
        return history;
    }

    /**
     * 删除会话(逻辑删除)
     */
    public void deleteSession(String sessionId, Integer userId) {
        LambdaQueryWrapper<ChatSession> wrapper = new LambdaQueryWrapper<>();
        wrapper.eq(ChatSession::getSessionId, sessionId)
               .eq(ChatSession::getUserId, userId);
        ChatSession session = sessionMapper.selectOne(wrapper);
        if (session != null) {
            sessionMapper.deleteById(session.getId());
        }
    }

    /**
     * 更新会话标题
     */
    public void updateSessionTitle(String sessionId, Integer userId, String title) {
        LambdaQueryWrapper<ChatSession> wrapper = new LambdaQueryWrapper<>();
        wrapper.eq(ChatSession::getSessionId, sessionId)
               .eq(ChatSession::getUserId, userId);
        ChatSession session = sessionMapper.selectOne(wrapper);
        if (session != null) {
            session.setTitle(title);
            session.setUpdatedAt(LocalDateTime.now());
            sessionMapper.updateById(session);
        }
    }

    /**
     * 检查会话是否存在
     */
    public boolean sessionExists(String sessionId, Integer userId) {
        LambdaQueryWrapper<ChatSession> wrapper = new LambdaQueryWrapper<>();
        wrapper.eq(ChatSession::getSessionId, sessionId)
               .eq(ChatSession::getUserId, userId);
        return sessionMapper.selectCount(wrapper) > 0;
    }
}

4.5 持久化对话流程

java 复制代码
public ChatResult chatWithPersistence(Integer userId, String sessionId,
                                       String userMessage) {
    // 1. 初始化用户ID
    if (userId == null) userId = generateUserId();

    // 2. 初始化会话ID
    if (sessionId == null || sessionId.isEmpty()) {
        sessionId = persistenceService.createSession(userId, userMessage);
    } else {
        if (!persistenceService.sessionExists(sessionId, userId)) {
            sessionId = persistenceService.createSession(userId, userMessage);
        }
    }

    // 3. 获取历史消息
    List<ChatRequest.Message> history = persistenceService.getHistoryForLLM(
            sessionId, maxHistoryMessages);

    // 4. 构建消息列表
    List<ChatRequest.Message> messages = new ArrayList<>();
    messages.add(ChatRequest.Message.system("你是一个智能助手"));
    messages.addAll(history);
    messages.add(ChatRequest.Message.user(userMessage));

    // 5. 调用 AI
    String reply = doChat(messages);

    // 6. 保存到数据库
    persistenceService.saveMessage(sessionId, "user", userMessage, null);
    persistenceService.saveMessage(sessionId, "assistant", reply, null);

    return new ChatResult(userId, sessionId, reply);
}

五、RAG 检索增强生成

RAG 是现在 AI 应用的标准配置。原理很简单:先搜相关知识,再把知识喂给 AI

5.1 RAG 的整体流程

复制代码
FAQ 文档 → 文本分割 → 向量化 → 存入向量库
                                    ↓
用户提问 → 向量化 → 相似度检索 → 取 Top3 → 拼入 System Prompt → 调 AI

5.2 文本分割

先准备一份 FAQ 文档,放在 src/main/resources/docs/学生管理系统FAQ.txt

复制代码
Q:如何修改密码?
A:在"个人中心"->"安全设置"中可修改登录密码,修改前需要验证原密码。

Q:如何选课?
A:登录系统后,进入"选课管理"模块,查看可选课程列表,点击"选课"按钮即可。

Q:如何查成绩?
A:进入"成绩查询"模块,选择学期和科目,点击"查询"即可查看成绩。

然后写一个分割器,把文档按 Q/A 拆成独立的 Document:

java 复制代码
// service/impl/TextSplitterImpl.java
@Slf4j
@Service
public class TextSplitterImpl {

    /**
     * 解析 QA 格式的文档
     */
    public List<Document> parseQADocuments(String content) {
        List<Document> docs = new ArrayList<>();
        String[] lines = content.split("\\n");
        String currentQ = null;
        StringBuilder currentA = new StringBuilder();

        for (String line : lines) {
            String trimmed = line.trim();
            if (trimmed.startsWith("Q:") || trimmed.startsWith("Q:")) {
                // 保存上一个 QA
                if (currentQ != null && currentA.length() > 0) {
                    docs.add(createDoc(currentQ, currentA.toString().trim()));
                }
                currentQ = trimmed.substring(2).trim();
                currentA = new StringBuilder();
            } else if (trimmed.startsWith("A:") || trimmed.startsWith("A:")) {
                currentA.append(trimmed.substring(2).trim()).append(" ");
            } else if (currentA.length() > 0) {
                currentA.append(trimmed).append(" ");
            }
        }
        // 处理最后一个 QA
        if (currentQ != null && currentA.length() > 0) {
            docs.add(createDoc(currentQ, currentA.toString().trim()));
        }
        log.info("解析 QA 格式完成,共 {} 条", docs.size());
        return docs;
    }

    /**
     * 创建 Document 对象
     */
    public Document createDoc(String question, String answer) {
        Document doc = new Document();
        doc.setContent(question);  // content 存问题(用于向量检索)
        doc.getMetadata().put("question", question);
        doc.getMetadata().put("answer", answer);
        return doc;
    }
}

// dto/bailian/rag/Document.java
@Data
public class Document {
    private String content;
    private Map<String, String> metadata = new HashMap<>();
}

5.3 文本向量化

向量化就是把文本转成浮点数数组。百炼的 Embedding API 也是 OpenAI 兼容的:

  • 请求地址https://{workspaceId}.cn-beijing.maas.aliyuncs.com/compatible-mode/v1/embeddings
  • 请求体{"model": "text-embedding-v4", "input": ["文本1", "文本2"]}
java 复制代码
// service/impl/EmbeddingServiceImpl.java
@Slf4j
@Service
public class EmbeddingServiceImpl {

    @Value("${bailian.api.api-key}")
    private String apiKey;
    @Value("${bailian.api.workspace-id}")
    private String workspaceId;

    private OkHttpClient httpClient;
    private ObjectMapper objectMapper;
    private static final String MODEL = "text-embedding-v4";
    private static final String BASE_URL =
            "https://%s.cn-beijing.maas.aliyuncs.com/compatible-mode/v1/embeddings";
    private static final int BATCH_SIZE = 10;  // 每批最多10条

    @PostConstruct
    public void init() {
        this.httpClient = new OkHttpClient.Builder()
                .connectTimeout(30, TimeUnit.SECONDS)
                .readTimeout(60, TimeUnit.SECONDS)
                .build();
        this.objectMapper = new ObjectMapper();
    }

    /**
     * 单条文本向量化
     */
    public float[] embed(String text) throws IOException {
        List<float[]> result = embedBatch(List.of(text));
        return result.isEmpty() ? new float[0] : result.get(0);
    }

    /**
     * 批量向量化(每批最多10条)
     */
    public List<float[]> embedBatch(List<String> texts) throws IOException {
        if (texts == null || texts.isEmpty()) return new ArrayList<>();

        List<float[]> allVectors = new ArrayList<>();
        String url = String.format(BASE_URL, workspaceId);

        for (int i = 0; i < texts.size(); i += BATCH_SIZE) {
            List<String> batch = texts.subList(i,
                    Math.min(i + BATCH_SIZE, texts.size()));

            // 构造请求体
            EmbeddingRequest req = new EmbeddingRequest();
            req.setModel(MODEL);
            req.setInput(batch);
            String json = objectMapper.writeValueAsString(req);

            // 发 HTTP 请求
            Request httpReq = new Request.Builder()
                    .url(url)
                    .post(RequestBody.create(json,
                            MediaType.parse("application/json")))
                    .addHeader("Authorization", "Bearer " + apiKey)
                    .build();

            try (Response response = httpClient.newCall(httpReq).execute()) {
                String body = response.body().string();
                if (!response.isSuccessful()) {
                    throw new IOException("Embedding 失败: " + body);
                }
                EmbeddingResponse embResp = objectMapper.readValue(
                        body, EmbeddingResponse.class);
                for (EmbeddingResponse.EmbeddingData d : embResp.getData()) {
                    // Double 列表转 float[]
                    float[] vec = new float[d.getEmbedding().size()];
                    for (int j = 0; j < vec.length; j++) {
                        vec[j] = d.getEmbedding().get(j).floatValue();
                    }
                    allVectors.add(vec);
                }
            }
        }
        return allVectors;
    }
}

// dto/bailian/rag/EmbeddingRequest.java
@Data
public class EmbeddingRequest {
    private String model;
    private List<String> input;
}

// dto/bailian/rag/EmbeddingResponse.java
@Data
@JsonIgnoreProperties(ignoreUnknown = true)
public class EmbeddingResponse {
    private List<EmbeddingData> data;

    @Data
    @JsonIgnoreProperties(ignoreUnknown = true)
    public static class EmbeddingData {
        private Integer index;
        private List<Double> embedding;
    }
}

5.4 内存向量库 + 余弦相似度检索

向量库就是个 List<VectorEntry>,每个 Entry 存文本内容和对应的向量。检索时用余弦相似度

java 复制代码
// service/MemoryVectorStore.java
@Slf4j
@Service
public class MemoryVectorStore implements ApplicationRunner {

    @Autowired
    private EmbeddingServiceImpl embeddingService;
    @Autowired
    private TextSplitterImpl textSplitter;

    private final List<VectorEntry> vectorStore = new ArrayList<>();
    private final AtomicBoolean ready = new AtomicBoolean(false);

    private static final double THRESHOLD = 0.65;  // 相似度阈值
    private static final int TOP_K = 3;             // 返回前3条

    /**
     * 应用启动时异步加载知识库
     */
    @Async
    @Override
    public void run(ApplicationArguments args) {
        try {
            // 读取 FAQ 文档
            ClassPathResource resource = new ClassPathResource("docs/学生管理系统FAQ.txt");
            StringBuilder content = new StringBuilder();
            try (BufferedReader reader = new BufferedReader(
                    new InputStreamReader(resource.getInputStream(), StandardCharsets.UTF_8))) {
                String line;
                while ((line = reader.readLine()) != null) {
                    content.append(line).append("\n");
                }
            }

            // 解析文档
            List<Document> documents = textSplitter.parseQADocuments(content.toString());

            // 向量化
            List<String> texts = documents.stream()
                    .map(Document::getContent)
                    .collect(Collectors.toList());
            List<float[]> vectors = embeddingService.embedBatch(texts);

            // 存入向量库
            for (int i = 0; i < documents.size(); i++) {
                VectorEntry entry = new VectorEntry();
                entry.setId(UUID.randomUUID().toString());
                entry.setContent(documents.get(i).getContent());
                entry.setVector(vectors.get(i));
                entry.setMetadata(documents.get(i).getMetadata());
                vectorStore.add(entry);
            }

            ready.set(true);
            log.info("内存向量库初始化完成,共 {} 条记录", vectorStore.size());
        } catch (Exception e) {
            log.error("向量库初始化失败", e);
        }
    }

    /**
     * 检索与查询最相关的文档
     */
    public List<VectorEntry> search(String query) {
        if (!ready.get()) return Collections.emptyList();

        try {
            // 查询向量化
            float[] qVec = embeddingService.embed(query);

            // 计算余弦相似度
            List<ScoredEntry> scored = new ArrayList<>();
            for (VectorEntry entry : vectorStore) {
                double sim = cosineSimilarity(qVec, entry.getVector());
                scored.add(new ScoredEntry(entry, sim));
            }

            // 按相似度降序排列
            scored.sort((a, b) -> Double.compare(b.similarity, a.similarity));

            // 取 Top3 且相似度 >= 阈值
            List<VectorEntry> results = new ArrayList<>();
            for (ScoredEntry se : scored) {
                if (se.similarity >= THRESHOLD && results.size() < TOP_K) {
                    results.add(se.entry);
                }
            }
            return results;
        } catch (Exception e) {
            log.error("检索失败", e);
            return Collections.emptyList();
        }
    }

    /**
     * 余弦相似度:cos(theta) = (A·B) / (|A| * |B|)
     */
    private double cosineSimilarity(float[] a, float[] b) {
        double dot = 0, na = 0, nb = 0;
        for (int i = 0; i < a.length; i++) {
            dot += a[i] * b[i];
            na += a[i] * a[i];
            nb += b[i] * b[i];
        }
        return dot / (Math.sqrt(na) * Math.sqrt(nb));
    }

    @Data
    public static class VectorEntry {
        private String id;
        private String content;
        private float[] vector;
        private Map<String, String> metadata = new HashMap<>();
    }

    @Data
    @AllArgsConstructor
    private static class ScoredEntry {
        VectorEntry entry;
        double similarity;
    }
}

5.5 把检索结果拼入 System Prompt

java 复制代码
// 在对话服务中
if (useRag) {
    List<VectorEntry> matched = vectorStore.search(userMessage);
    if (matched != null && !matched.isEmpty()) {
        StringBuilder context = new StringBuilder();
        for (VectorEntry entry : matched) {
            String answer = entry.getMetadata().get("answer");
            if (answer != null) {
                context.append("- ").append(answer).append("\n");
            }
        }
        String systemPrompt = "你是一个智能助手。请根据以下参考资料回答用户的问题," +
                "如果资料没有相关答案,结合最符合的资料进行补充回答," +
                "毫无相关资料则回答暂时无法回答建议提工单处理。\n\n" +
                "【参考资料】\n" + context.toString();
        messages.add(ChatRequest.Message.system(systemPrompt));
    }
}

这样,当用户问"如何修改密码"时,AI 会先检索到相关的 FAQ 答案,然后基于这个答案来回答用户。


六、Tool Calls --- 让 AI 调用你的后端函数

这是最有意思的部分。通过 Function Calling,AI 可以自动判断什么时候需要调用你的后端方法。

6.1 什么是 Tool Calls

简单说,就是你在请求里告诉 AI:"我有这些工具可以用",然后 AI 在回复时如果判断需要调用工具,会返回一个 tool_calls 字段,你执行完工具后把结果再发给 AI,AI 再生成最终回复。

举个例子:

复制代码
你发给 AI:
{
  "messages": [{"role": "user", "content": "2@4等于多少"}],
  "tools": [{
    "type": "function",
    "function": {
      "name": "calculate_at_operation",
      "description": "计算两个数字之间的特殊运算(@运算),公式是 a * b + 100",
      "parameters": {
        "type": "object",
        "properties": {
          "a": {"type": "integer", "description": "@符号左边的数字"},
          "b": {"type": "integer", "description": "@符号右边的数字"}
        },
        "required": ["a", "b"]
      }
    }
  }]
}

AI 返回:
{
  "choices": [{
    "message": {
      "role": "assistant",
      "tool_calls": [{
        "id": "call_xxx",
        "function": {
          "name": "calculate_at_operation",
          "arguments": "{\"a\": 2, \"b\": 4}"
        }
      }]
    },
    "finish_reason": "tool_calls"
  }]
}

然后你执行 calculate_at_operation(2, 4) 得到结果 108,再发给 AI:

复制代码
{
  "messages": [
    {"role": "user", "content": "2@4等于多少"},
    {"role": "assistant", "tool_calls": [...]},
    {"role": "tool", "tool_call_id": "call_xxx", "content": "计算结果: 2 @ 4 = 108"}
  ],
  "tools": [...]
}

AI 最终返回:
{"choices": [{"message": {"content": "2@4 的计算结果是 108"}, "finish_reason": "stop"}]}

6.2 自定义注解

为了优雅地注册工具方法,我们定义两个注解:

java 复制代码
// annotation/ToolMethod.java
@Target(ElementType.METHOD)
@Retention(RetentionPolicy.RUNTIME)
@Documented
public @interface ToolMethod {
    String name() default "";          // 工具名称
    String description() default "";   // 工具描述(给 AI 看的)
    boolean enabled() default true;    // 是否启用
}

// annotation/ToolParam.java
@Target(ElementType.PARAMETER)
@Retention(RetentionPolicy.RUNTIME)
@Documented
public @interface ToolParam {
    String name() default "";          // 参数名称
    String description() default "";   // 参数描述(给 AI 看的)
    boolean required() default true;   // 是否必填
}

6.3 工具注册中心

ToolRegistry 实现了 BeanPostProcessor,在 Spring 容器初始化 Bean 后自动扫描带 @ToolMethod 的方法:

java 复制代码
// register/ToolRegistry.java
@Slf4j
@Component
public class ToolRegistry implements BeanPostProcessor, ApplicationContextAware {

    private ApplicationContext applicationContext;
    private final Map<String, ToolRegistration> registry = new ConcurrentHashMap<>();
    private final ObjectMapper mapper = new ObjectMapper();

    @Override
    public void setApplicationContext(ApplicationContext ctx) throws BeansException {
        this.applicationContext = ctx;
    }

    // Bean 初始化后自动扫描
    @Override
    public Object postProcessAfterInitialization(Object bean, String beanName) {
        scanBean(bean);
        return bean;
    }

    private void scanBean(Object bean) {
        Class<?> clazz = bean.getClass();
        // 处理 CGLIB 代理
        if (clazz.getName().contains("$$")) clazz = clazz.getSuperclass();
        for (Method method : clazz.getDeclaredMethods()) {
            ToolMethod tm = method.getAnnotation(ToolMethod.class);
            if (tm == null || !tm.enabled()) continue;
            ToolRegistration reg = buildRegistration(tm, method, bean);
            if (reg != null) {
                registry.put(reg.getName(), reg);
                log.info("注册工具: {}", reg.getName());
            }
        }
    }

    private ToolRegistration buildRegistration(ToolMethod tm, Method method, Object bean) {
        String name = tm.name().isEmpty() ? method.getName() : tm.name();
        ToolRegistration reg = new ToolRegistration();
        reg.setName(name);
        reg.setDescription(tm.description());
        reg.setMethod(method);
        reg.setTarget(bean);

        List<ToolParameter> params = new ArrayList<>();
        for (Parameter p : method.getParameters()) {
            ToolParam tp = p.getAnnotation(ToolParam.class);
            ToolParameter param = new ToolParameter();
            if (tp != null) {
                param.setName(tp.name().isEmpty() ? p.getName() : tp.name());
                param.setDescription(tp.description());
                param.setRequired(tp.required());
            } else {
                param.setName(p.getName());
                param.setDescription("参数 " + p.getName());
                param.setRequired(true);
            }
            param.setType(p.getType());
            param.setJsonType(mapType(p.getType()));
            params.add(param);
        }
        reg.setParameters(params);
        return reg;
    }

    // Java 类型 -> JSON Schema 类型
    private String mapType(Class<?> type) {
        if (type == String.class) return "string";
        if (type == Integer.class || type == int.class) return "integer";
        if (type == Long.class || type == long.class) return "integer";
        if (type == Double.class || type == double.class ||
            type == Float.class || type == float.class) return "number";
        if (type == Boolean.class || type == boolean.class) return "boolean";
        if (type == List.class || type.isArray()) return "array";
        return "object";
    }

    /**
     * 生成 Function Calling Schema(发给 AI 的 tools 定义)
     */
    public List<ChatRequest.Tool> getToolDefinitions() {
        List<ChatRequest.Tool> tools = new ArrayList<>();
        for (ToolRegistration reg : registry.values()) {
            ChatRequest.Tool tool = new ChatRequest.Tool();
            tool.setType("function");
            ChatRequest.FunctionDef func = new ChatRequest.FunctionDef();
            func.setName(reg.getName());
            func.setDescription(reg.getDescription());

            // 构造 JSON Schema 参数定义
            Map<String, Object> params = new LinkedHashMap<>();
            params.put("type", "object");
            Map<String, Object> props = new LinkedHashMap<>();
            List<String> required = new ArrayList<>();
            for (ToolParameter p : reg.getParameters()) {
                Map<String, Object> prop = new LinkedHashMap<>();
                prop.put("type", p.getJsonType());
                prop.put("description", p.getDescription());
                props.put(p.getName(), prop);
                if (p.isRequired()) required.add(p.getName());
            }
            params.put("properties", props);
            if (!required.isEmpty()) params.put("required", required);
            func.setParameters(params);
            tool.setFunction(func);
            tools.add(tool);
        }
        return tools;
    }

    /**
     * 执行工具(反射调用)
     */
    public String execute(String toolName, String arguments) {
        ToolRegistration reg = registry.get(toolName);
        if (reg == null) return "未知工具: " + toolName;
        try {
            JsonNode argsNode = mapper.readTree(arguments);
            Method method = reg.getMethod();
            Object[] args = new Object[method.getParameterCount()];
            for (int i = 0; i < method.getParameters().length; i++) {
                ToolParameter p = reg.getParameters().get(i);
                JsonNode val = argsNode.path(p.getName());
                args[i] = convert(val, p.getType());
            }
            Object result = method.invoke(reg.getTarget(), args);
            return result != null ? result.toString() : "执行成功(无返回值)";
        } catch (Exception e) {
            log.error("执行工具失败: {}", toolName, e);
            return "工具执行失败: " + e.getMessage();
        }
    }

    private Object convert(JsonNode node, Class<?> type) {
        if (node.isNull()) return null;
        if (type == String.class) return node.asText();
        if (type == Integer.class || type == int.class) return node.asInt();
        if (type == Long.class || type == long.class) return node.asLong();
        if (type == Double.class || type == double.class) return node.asDouble();
        if (type == Boolean.class || type == boolean.class) return node.asBoolean();
        try {
            return mapper.treeToValue(node, type);
        } catch (Exception e) {
            return node.asText();
        }
    }
}

// 工具注册信息类
@Data
class ToolRegistration {
    private String name;
    private String description;
    private Method method;
    private Object target;
    private List<ToolParameter> parameters = new ArrayList<>();

    @Data
    static class ToolParameter {
        private String name;
        private String description;
        private boolean required;
        private Class<?> type;
        private String jsonType;
    }
}

6.4 写一个实际的工具方法

java 复制代码
// tools/MathTool.java
@Slf4j
@Service
public class MathTool {

    @ToolMethod(
            name = "calculate_at_operation",
            description = "计算两个数字之间的特殊运算(@运算)。" +
                    "当用户输入包含 '@' 符号的数学表达式时,必须调用此工具。" +
                    "例如 '2@4' 表示 a=2, b=4,返回运算结果。"
    )
    public String calculateAtOperation(
            @ToolParam(name = "a", description = "@符号左边的数字") Integer a,
            @ToolParam(name = "b", description = "@符号右边的数字") Integer b
    ) {
        int result = a * b + 100;
        return String.format("计算结果: %d @ %d = %d", a, b, result);
    }
}

重点提醒: @ToolMethoddescription 字段至关重要!它直接决定了 AI 什么时候会调用这个工具。描述越清晰准确,AI 就越能正确判断调用时机。

6.5 在 ChatService 中处理 Tool Calls

回到 BailianChatServiceImpl.java,改造 doChat 方法,支持 Tool Calls 的递归处理:

java 复制代码
public String doChatWithTools(List<ChatRequest.Message> messages,
                               List<ChatRequest.Tool> tools) {
    try {
        if (tools == null) {
            tools = toolRegistry.getToolDefinitions();
        }

        // 1. 构造请求体
        ChatRequest request = new ChatRequest();
        request.setModel(model);
        request.setMessages(messages);
        request.setStream(false);
        request.setTools(tools);
        request.setToolChoice("auto");

        // 2. 发 HTTP 请求(同前面的代码)
        String url = String.format(BASE_URL, workspaceId);
        String jsonBody = objectMapper.writeValueAsString(request);

        Request httpRequest = new Request.Builder()
                .url(url)
                .post(RequestBody.create(jsonBody,
                        MediaType.parse("application/json")))
                .addHeader("Authorization", "Bearer " + apiKey)
                .addHeader("Content-Type", "application/json")
                .build();

        try (Response response = httpClient.newCall(httpRequest).execute()) {
            String responseBody = response.body().string();
            if (!response.isSuccessful()) {
                return "AI服务暂时不可用 (状态码: " + response.code() + ")";
            }

            ChatResponse chatResponse = objectMapper.readValue(
                    responseBody, ChatResponse.class);

            ChatResponse.Message msg = chatResponse.getChoices().get(0).getMessage();

            // 3. 检查是否有工具调用
            List<ChatRequest.ToolCall> toolCalls = msg.getToolCalls();
            if (toolCalls != null && !toolCalls.isEmpty()) {
                log.info("检测到 {} 个工具调用", toolCalls.size());

                // 添加 assistant 消息(含 tool_calls)
                ChatRequest.Message assistantMsg =
                        ChatRequest.Message.assistant(msg.getContent());
                assistantMsg.setToolCalls(toolCalls);
                messages.add(assistantMsg);

                // 执行各个工具,添加 tool 消息
                for (ChatRequest.ToolCall tc : toolCalls) {
                    String result = toolRegistry.execute(
                            tc.getFunction().getName(),
                            tc.getFunction().getArguments());
                    messages.add(ChatRequest.Message.tool(
                            tc.getId(), result));
                }

                // 4. 递归调用,让模型根据工具结果生成最终回答
                return doChatWithTools(messages, tools);
            }

            return msg.getContent() != null ? msg.getContent() : "";
        }
    } catch (IOException e) {
        return "AI服务异常: " + e.getMessage();
    }
}

需要在 ChatRequestChatResponse 里补充 Tool 相关的内部类:

java 复制代码
// ChatRequest.java 补充
@Data
public static class Tool {
    private String type = "function";
    private FunctionDef function;
}

@Data
public static class FunctionDef {
    private String name;
    private String description;
    private Map<String, Object> parameters;  // JSON Schema
}

@Data
public static class ToolCall {
    private String id;
    private String type = "function";
    private FunctionCall function;
}

@Data
public static class FunctionCall {
    private String name;
    private String arguments;
}

// Message 补充 toolCalls 和 toolCallId 字段
@Data
public static class Message {
    private String role;
    private String content;
    private List<ToolCall> toolCalls;    // assistant 消息用
    private String toolCallId;           // tool 消息用

    public static Message tool(String toolCallId, String content) {
        Message m = new Message();
        m.setRole("tool");
        m.setContent(content);
        m.setToolCallId(toolCallId);
        return m;
    }
}

6.6 完整调用流程

用户问 "2@4等于多少",整个流程是这样的:

复制代码
1. 用户 → POST /api/chat/assistant {"message": "2@4等于多少"}

2. MemoryChatService 编排:
   ├── 构建 messages: [system, user("2@4等于多少")]
   ├── 获取 tools 定义: [{name: "calculate_at_operation", ...}]
   └── 调用 BailianChatService.doChatWithTools()

3. 第一次调百炼 API:
   请求: {messages: [...], tools: [...]}
   响应: {finish_reason: "tool_calls", tool_calls: [{name: "calculate_at_operation", arguments: {"a":2,"b":4}}]}

4. 系统执行工具:
   calculateAtOperation(2, 4) → "计算结果: 2 @ 4 = 108"

5. 第二次调百炼 API(递归):
   请求: {messages: [system, user, assistant(tool_calls), tool("108")], tools: [...]}
   响应: {finish_reason: "stop", content: "2@4 的计算结果是 108"}

6. 返回给用户:
   {"reply": "2@4 的计算结果是 108"}

七、完整的 API 接口

把所有功能串起来,最终的 Controller 是这样的:

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

    @Autowired
    private MemoryChatService memoryChatService;
    @Autowired
    private ConversationService conversationService;
    @Autowired
    private PersistenceConversationService persistenceService;

    // 1. 全功能对话(RAG + 工具 + 内存记忆)
    @PostMapping("/assistant")
    public Result<ChatResult> assistant(@RequestBody AssistantRequest req) {
        return Result.success(memoryChatService.chatWithMemory(
                req.getUserId(), req.getMessage(), true, true));
    }

    // 2. 可控对话(可开关 RAG 和工具)
    @PostMapping("/memory")
    public Result<ChatResult> memory(@RequestBody MemoryRequest req) {
        return Result.success(memoryChatService.chatWithMemory(
                req.getUserId(), req.getMessage(),
                req.isUseRag(), req.isUseTools()));
    }

    // 3. 清除内存历史
    @DeleteMapping("/history/{userId}")
    public Result<String> clearHistory(@PathVariable Integer userId) {
        conversationService.clearHistory(userId);
        return Result.success("历史已清除");
    }

    // 4. 持久化会话对话
    @PostMapping("/session/chat")
    public Result<ChatResult> sessionChat(@RequestBody AssistantRequest req) {
        return Result.success(memoryChatService.chatWithPersistenceMemory(
                req.getUserId(), req.getSessionId(), req.getMessage(), true, true));
    }

    // 5. 获取会话列表
    @GetMapping("/sessions")
    public Result<List<ChatSession>> getUserSessions(@RequestParam Integer userId) {
        return Result.success(persistenceService.getUserSessions(userId));
    }

    // 6. 删除会话
    @PostMapping("/sessions/{sessionId}")
    public Result<Boolean> deleteSession(@PathVariable String sessionId,
                                          @RequestParam Integer userId) {
        persistenceService.deleteSession(sessionId, userId);
        return Result.success(true);
    }

    // 7. 更新会话标题
    @PostMapping("/sessions/title")
    public Result<Boolean> updateTitle(@RequestBody AssistantUpdateSessionRequest req) {
        persistenceService.updateSessionTitle(
                req.getSessionId(), req.getUserId(), req.getTitle());
        return Result.success(true);
    }

    // 8. 获取会话消息
    @PostMapping("/messages")
    public Result<List<ChatMessage>> getSessionMessages(
            @RequestBody SessionMessageRequest request) {
        return Result.success(persistenceService.getSessionMessages(request));
    }
}

// 统一响应格式
@Data
public class Result<T> {
    private int code;
    private String message;
    private T data;

    public static <T> Result<T> success(T data) {
        return new Result<>(200, "Success", data);
    }
    public static <T> Result<T> error(String message) {
        return new Result<>(500, message, null);
    }
}

八、踩坑记录

8.1 URL 格式

百炼的 API 地址是 https://{workspaceId}.cn-beijing.maas.aliyuncs.com/compatible-mode/v1/chat/completions,注意 workspaceId 是子域名,不是路径参数。我一开始写成了路径参数,调了半天一直 404。

8.2 认证 Header

Authorization: Bearer {apiKey},注意 Bearer 后面有个空格。少了这个空格,认证会失败。

8.3 响应解析

百炼返回的字段可能比 OpenAI 多,DTO 一定要加 @JsonIgnoreProperties(ignoreUnknown = true),否则 Jackson 会报 Unrecognized field 异常。

8.4 Tool Calls 递归

模型返回 tool_calls 后,必须把工具结果追加到 messages 里,再调一次 API。这个递归过程可能不止一轮(模型可能连续调用多个工具),所以要用递归而不是 if-else。

8.5 向量库异步加载

MemoryVectorStore 实现了 ApplicationRunner,用 @Async 异步加载知识库。如果不加 @Async,知识库加载会阻塞应用启动,如果 API 调不通,整个应用就起不来。记得在启动类加 @EnableAsync

8.6 历史消息截断

百炼的模型有 token 限制,历史消息不能无限累积。我在 ConversationServiceImpl 里加了截断逻辑,超过 maxHistoryMessages 条时自动丢弃旧消息。


九、总结

整个项目的核心思路就一句话:把百炼当成普通的 HTTP API 来调,自己构造请求体、自己解析响应、自己处理 Tool Calls 的递归调用

不依赖 SDK 的好处是:

  1. 透明:每一行 HTTP 请求都清清楚楚,出了问题好排查
  2. 轻量:不用引入一堆依赖
  3. 灵活:想加什么功能自己改,不受 SDK 限制

核心代码量其实不大:

  • HTTP 请求:OkHttp 20 行
  • JSON 序列化:Jackson 自动搞定
  • Tool Calls 注册:注解 + 反射,100 行左右
  • RAG 检索:向量化 + 余弦相似度,50 行左右
  • 持久化:MyBatis Plus 基本 CRUD

加起来不到 500 行核心代码,就跑通了 RAG + 会话持久化 + Tool Calls 三个功能。


十、项目结构

复制代码
src/main/java/com/sun/student_management_http_ai/
├── annotation/
│   ├── ToolMethod.java              # 工具方法注解
│   └── ToolParam.java               # 工具参数注解
├── config/
│   └── ChatProperties.java          # 百炼配置属性
├── controller/
│   └── ChatController.java          # AI 聊天接口
├── dto/
│   ├── base/Result.java             # 统一响应格式
│   └── bailian/
│       ├── ChatRequest.java         # 聊天请求 DTO
│       ├── ChatResponse.java        # 聊天响应 DTO
│       ├── ChatResult.java          # 聊天结果 DTO
│       └── rag/
│           ├── Document.java        # RAG 文档
│           ├── EmbeddingRequest.java
│           └── EmbeddingResponse.java
├── entity/
│   ├── ChatSession.java             # 会话实体
│   └── ChatMessage.java             # 消息实体
├── mapper/
│   ├── ChatSessionMapper.java
│   └── ChatMessageMapper.java
├── register/
│   └── ToolRegistry.java            # 工具注册中心
├── service/
│   ├── MemoryChatService.java       # 对话编排服务
│   ├── MemoryVectorStore.java       # 内存向量库
│   ├── EmbeddingService.java        # 向量化服务
│   ├── ConversationService.java     # 内存对话历史
│   └── impl/
│       ├── BailianChatServiceImpl.java  # 百炼 API 调用(OkHttp)
│       ├── EmbeddingServiceImpl.java    # 向量化实现(OkHttp)
│       ├── ChatPersistenceServiceImpl.java  # 持久化实现
│       ├── ConversationServiceImpl.java   # 内存对话实现
│       └── TextSplitterImpl.java          # 文本分割实现
└── tools/
    └── MathTool.java                # 数学工具示例

src/main/resources/
├── application.yml
├── docs/学生管理系统FAQ.txt         # RAG 知识库
└── mapper/
    ├── ChatSessionMapper.xml
    └── ChatMessageMapper.xml

最后说一句:如果你也在做 AI 集成,强烈建议先用原生 HTTP 调通,再考虑要不要用 SDK。理解了底层协议,用 SDK 就是降维打击。有问题欢迎交流~