SPRING 6 分钟 7,750 字

原生对接豆包

0阅读

原生对接

  1. 申请密钥(5 分钟)

  1. 注册/登录火山引擎 → 进入「火山方舟」控制台
  2. 左侧「模型推理」→「在线推理」→ 创建接入点(记下模型名,如 ep-2025xxxx-xxxxx)
  3. 右上角头像 → 「API 访问密钥」→ 创建并保存 API Key(格式:Bearer xxxxx)

  1. 项目结构

demo
 ├─ src/main/java/com/example/demo
 │   ├─ DemoApplication.java
 │   ├─ controller/DoubaoController.java
 │   └─ service/DoubaoService.java
 └─ application.yml

  1. 依赖(pom.xml 片段)

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

<dependencies>
    <dependency>
        <groupId>org.springframework.boot</groupId>
        <artifactId>spring-boot-starter-web</artifactId>
    </dependency>
    <dependency>
        <groupId>org.projectlombok</groupId>
        <artifactId>lombok</artifactId>
        <optional>true</optional>
    </dependency>
</dependencies>

  1. 配置(application.yml)

doubao:
  api-key: Bearer 这里填你的APIKey
  model: ep-2025xxxx-xxxxx          # 接入点模型名称
  url: https://ark.cn-beijing.volces.com/api/v3/chat/completions

  1. Service 层

package com.example.demo.service;

import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.http.*;
import org.springframework.stereotype.Service;
import org.springframework.web.client.RestTemplate;

import java.util.List;
import java.util.Map;

@Slf4j
@Service
@RequiredArgsConstructor
public class DoubaoService {

    private final RestTemplate restTemplate = new RestTemplate();

    @Value("${doubao.url}")
    private String url;

    @Value("${doubao.api-key}")
    private String apiKey;

    @Value("${doubao.model}")
    private String model;

    /**
     * 单次对话:用户输入 -> AI 回答
     */
    public String chat(String userText) {
        // 1. 拼装请求体
        Map<String, Object> body = Map.of(
                "model", model,
                "messages", List.of(
                        Map.of("role", "user", "content", userText)
                ),
                "stream", false
        );

        // 2. 头信息
        HttpHeaders headers = new HttpHeaders();
        headers.setContentType(MediaType.APPLICATION_JSON);
        headers.set("Authorization", apiKey);

        // 3. 发请求
        ResponseEntity<Map> resp = restTemplate.exchange(
                url,
                HttpMethod.POST,
                new HttpEntity<>(body, headers),
                Map.class
        );

        // 4. 解析结果
        if (resp.getStatusCode() != HttpStatus.OK || resp.getBody() == null) {
            throw new RuntimeException("豆包接口异常:" + resp.getStatusCode());
        }
        List<Map<String, Object>> choices = (List<Map<String, Object>>) resp.getBody().get("choices");
        if (choices == null || choices.isEmpty()) {
            return "";
        }
        Map<String, Object> msg = (Map<String, Object>) choices.get(0).get("message");
        return (String) msg.get("content");
    }
}

  1. Controller 层

package com.example.demo.controller;

import com.example.demo.service.DoubaoService;
import lombok.RequiredArgsConstructor;
import org.springframework.web.bind.annotation.*;

@RestController
@RequestMapping("/ai")
@RequiredArgsConstructor
@CrossOrigin
public class DoubaoController {

    private final DoubaoService doubaoService;

    /**
     * POST /ai/chat
     * 请求体:{"q":"你好,豆包!"}
     */
    @PostMapping("/chat")
    public String chat(@RequestBody Map<String,String> param) {
        return doubaoService.chat(param.getOrDefault("q", "你好"));
    }
}

  1. 启动类

package com.example.demo;

import org.springframework.boot.SpringApplication;
import org.springframework.boot.autoconfigure.SpringBootApplication;

@SpringBootApplication
public class DemoApplication {
    public static void main(String[] args) {
        SpringApplication.run(DemoApplication.class, args);
    }
}

  1. 运行 & 测试

  1. 启动 DemoApplication
  2. 终端验证
curl -X POST http://localhost:8080/ai/chat \
     -H "Content-Type: application/json" \
     -d '{"q":"用一句话介绍你自己"}'

流式输出


  1. 项目依赖(与上一版相同,不再赘述)

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

<dependencies>
    <dependency>
        <groupId>org.springframework.boot</groupId>
        <artifactId>spring-boot-starter-web</artifactId>
    </dependency>
    <dependency>
        <groupId>org.projectlombok</groupId>
        <artifactId>lombok</artifactId>
        <optional>true</optional>
    </dependency>
</dependencies>

  1. 配置(application.yml)

doubao:
  api-key: Bearer 你的APIKey
  model: ep-2025xxxx-xxxxx          # 接入点名称
  url: https://ark.cn-beijing.volces.com/api/v3/chat/completions

  1. Service 层(流式核心)

package com.example.demo.service;

import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.http.*;
import org.springframework.http.client.ClientHttpResponse;
import org.springframework.stereotype.Service;
import org.springframework.web.client.RequestCallback;
import org.springframework.web.client.ResponseExtractor;
import org.springframework.web.client.RestTemplate;
import org.springframework.web.servlet.mvc.method.annotation.SseEmitter;
import tools.jackson.databind.ObjectMapper;

import java.io.BufferedReader;
import java.io.InputStreamReader;
import java.nio.charset.StandardCharsets;
import java.util.List;
import java.util.Map;
import java.util.concurrent.ExecutorService;
import java.util.concurrent.Executors;

@Slf4j
@Service
@RequiredArgsConstructor
public class DoubaoStreamService {

    private final RestTemplate restTemplate = new RestTemplate();
    private final ExecutorService pool = Executors.newCachedThreadPool();

    @Value("${doubao.url}")
    private String url;

    @Value("${doubao.api-key}")
    private String apiKey;

    @Value("${doubao.model}")
    private String model;

    /**
     * 流式对话:返回 SseEmitter,前端订阅即可
     */
    public SseEmitter stream(String userText) {
        SseEmitter emitter = new SseEmitter(0L); // 0 表示永不超时

        pool.execute(() -> {
            try {
                // 1. 请求体(stream=true 是关键)
                Map<String, Object> body = Map.of(
                        "model", model,
                        "messages", List.of(Map.of("role", "user", "content", userText)),
                        "stream", true
                );

                RequestCallback requestCallback = request -> {
                    request.getHeaders().setContentType(MediaType.APPLICATION_JSON);
                    request.getHeaders().set("Authorization", apiKey);
                    // 关键:用 Jackson 把 Map 转成字节数组
                    new ObjectMapper()
                            .writeValue(request.getBody(), body);
                };

                ResponseExtractor<Void> responseExtractor = response -> {
                    try (BufferedReader reader = new BufferedReader(
                            new InputStreamReader(response.getBody(), StandardCharsets.UTF_8))) {
                        String line;
                        while ((line = reader.readLine()) != null) {
                            // 火山方舟 SSE 格式:data: {...} 或 data: [DONE]
                            if (line.startsWith("data:")) {
                                String json = line.substring(5).trim();
                                if ("[DONE]".equals(json)) {
                                    emitter.send(SseEmitter.event().name("stop").data(""));
                                    break;
                                }
                                // 只取 delta.content
                                String content = parseDelta(json);
                                if (content != null) {
                                    emitter.send(SseEmitter.event().data(content));
                                }
                            }
                        }
                    }
                    return null;
                };

                restTemplate.execute(url, HttpMethod.POST, requestCallback, responseExtractor);

            } catch (Exception e) {
                log.error("流式调用异常", e);
                try {
                    emitter.send(SseEmitter.event().name("error").data("exception"));
                } catch (Exception ignore) {
                }
            } finally {
                try {
                    emitter.complete();
                } catch (Exception ignore) {
                }
            }
        });

        return emitter;
    }

    /* 简易解析:只提取 choices[0].delta.content */
    private String parseDelta(String json) {
        try {
            ObjectMapper mapper = new ObjectMapper();
            Map<?,?> root = mapper.readValue(json, Map.class);
            List<?> choices = (List<?>) root.get("choices");
            if (choices != null && !choices.isEmpty()) {
                Map<?,?> delta = (Map<?,?>) ((Map<?,?>) choices.get(0)).get("delta");
                if (delta != null && delta.containsKey("content")) {
                    return (String) delta.get("content");
                }
            }
        } catch (Exception ignore) {
        }
        return null;
    }
}

  1. Controller 层

package com.example.demo.controller;

import com.example.demo.service.DoubaoStreamService;
import lombok.RequiredArgsConstructor;
import org.springframework.web.bind.annotation.*;
import org.springframework.web.servlet.mvc.method.annotation.SseEmitter;

@RestController
@RequestMapping("/ai")
@RequiredArgsConstructor
@CrossOrigin
public class StreamController {

    private final DoubaoStreamService streamService;

    /**
     * GET /ai/stream?msg=你好
     * 前端:new EventSource("/ai/stream?msg=你好")
     */
    @GetMapping(value = "/stream", produces = MediaType.TEXT_EVENT_STREAM_VALUE)
    public SseEmitter stream(@RequestParam String msg) {
        return streamService.stream(msg);
    }
}

  1. 前端最简示例(纯 HTML)

<!doctype html>
<html>
<body>
  <input id="q" placeholder="输入问题"/>
  <button onclick="ask()">发送</button>
  <pre id="ans"></pre>

<script>
function ask(){
  const q = document.getElementById('q').value.trim();
  if(!q) return;
  document.getElementById('ans').textContent = '';
  const evt = new EventSource('/ai/stream?msg='+encodeURIComponent(q));
  evt.onmessage = e => document.getElementById('ans').textContent += e.data;
  evt.addEventListener('stop', () => evt.close());
  evt.addEventListener('error', () => { evt.close(); console.error('SSE error') });
}
</script>
</body>
</html>