LLM API 协议代理 —— 从零到一全栈教程

LLM API 协议代理 ------ 从零到一全栈教程

项目名称llm-protocol-proxy

技术栈 :Spring Boot 4.1.1 + Java 25 + RestClient(非流式) + WebClient(Reactor Netty, 流式) + 前端原生 JS

核心功能 :原生透传 OpenAI Chat Completions / OpenAI Responses / Anthropic Messages 三种 LLM 协议,实现非流式(RestClient 同步)与流式(WebClient SSE)双模式对话

学习路线设计:零跳跃------每章末尾的"最后一公里"恰好是下一章的"第一公里"


目录

  • [第一章 大模型基础与 API 协议世界](#第一章 大模型基础与 API 协议世界)
  • [第二章 项目架构全景](#第二章 项目架构全景)
  • [第三章 项目骨架搭建------依赖、配置与启动](#第三章 项目骨架搭建——依赖、配置与启动)
  • [第四章 HTTP 客户端------RestClient 与 WebClient 的选型与配置](#第四章 HTTP 客户端——RestClient 与 WebClient 的选型与配置)
  • [第五章 三大 LLM 协议深度解析](#第五章 三大 LLM 协议深度解析)
  • [第六章 Service 层------协议透传的心脏](#第六章 Service 层——协议透传的心脏)
  • [第七章 Controller 层------HTTP 端点暴露](#第七章 Controller 层——HTTP 端点暴露)
  • [第八章 异常处理体系](#第八章 异常处理体系)
  • [第九章 前端------对话交互界面](#第九章 前端——对话交互界面)
  • [第十章 测试与验证](#第十章 测试与验证)

第一章 大模型基础与 API 协议世界

本章定位:从"什么是大模型"出发,建立对 LLM API 调用链路的整体认知。读完本章,你将理解"为什么需要一个代理服务"以及"代理服务在架构中扮演什么角色"。

1.1 什么是大语言模型(LLM)

大语言模型(Large Language Model, LLM)是一类基于 Transformer 架构训练的人工智能模型,能够理解和生成自然语言文本。常见的商用大模型包括:

厂商 代表模型 协议风格
OpenAI GPT-4o / o1 / o3 Chat Completions / Responses
Anthropic Claude 4 Opus / Sonnet Messages
Google Gemini 2.5 Pro OpenAI 兼容
国内厂商 通义千问 / DeepSeek / MiMo OpenAI 兼容为主

关键认知 :大模型本身只是一个"大脑",要让它工作,你需要通过 HTTP API 向它发送请求、接收响应。LLM API 协议就是"你用什么格式跟大模型对话"的规范。

1.2 为什么会有多种协议

你可能会困惑:既然都是"跟大模型对话",为什么 OpenAI 要搞 Chat Completions 和 Responses 两种协议?Anthropic 又要搞一套 Messages 协议?

原因一:历史演进
#mermaid-svg-Nq8dsL6MBKWLDHVo{font-family:"trebuchet ms",verdana,arial,sans-serif;font-size:16px;fill:#333;}@keyframes edge-animation-frame{from{stroke-dashoffset:0;}}@keyframes dash{to{stroke-dashoffset:0;}}#mermaid-svg-Nq8dsL6MBKWLDHVo .edge-animation-slow{stroke-dasharray:9,5!important;stroke-dashoffset:900;animation:dash 50s linear infinite;stroke-linecap:round;}#mermaid-svg-Nq8dsL6MBKWLDHVo .edge-animation-fast{stroke-dasharray:9,5!important;stroke-dashoffset:900;animation:dash 20s linear infinite;stroke-linecap:round;}#mermaid-svg-Nq8dsL6MBKWLDHVo .error-icon{fill:#552222;}#mermaid-svg-Nq8dsL6MBKWLDHVo .error-text{fill:#552222;stroke:#552222;}#mermaid-svg-Nq8dsL6MBKWLDHVo .edge-thickness-normal{stroke-width:1px;}#mermaid-svg-Nq8dsL6MBKWLDHVo .edge-thickness-thick{stroke-width:3.5px;}#mermaid-svg-Nq8dsL6MBKWLDHVo .edge-pattern-solid{stroke-dasharray:0;}#mermaid-svg-Nq8dsL6MBKWLDHVo .edge-thickness-invisible{stroke-width:0;fill:none;}#mermaid-svg-Nq8dsL6MBKWLDHVo .edge-pattern-dashed{stroke-dasharray:3;}#mermaid-svg-Nq8dsL6MBKWLDHVo .edge-pattern-dotted{stroke-dasharray:2;}#mermaid-svg-Nq8dsL6MBKWLDHVo .marker{fill:#333333;stroke:#333333;}#mermaid-svg-Nq8dsL6MBKWLDHVo .marker.cross{stroke:#333333;}#mermaid-svg-Nq8dsL6MBKWLDHVo svg{font-family:"trebuchet ms",verdana,arial,sans-serif;font-size:16px;}#mermaid-svg-Nq8dsL6MBKWLDHVo p{margin:0;}#mermaid-svg-Nq8dsL6MBKWLDHVo .label{font-family:"trebuchet ms",verdana,arial,sans-serif;color:#333;}#mermaid-svg-Nq8dsL6MBKWLDHVo .cluster-label text{fill:#333;}#mermaid-svg-Nq8dsL6MBKWLDHVo .cluster-label span{color:#333;}#mermaid-svg-Nq8dsL6MBKWLDHVo .cluster-label span p{background-color:transparent;}#mermaid-svg-Nq8dsL6MBKWLDHVo .label text,#mermaid-svg-Nq8dsL6MBKWLDHVo span{fill:#333;color:#333;}#mermaid-svg-Nq8dsL6MBKWLDHVo .node rect,#mermaid-svg-Nq8dsL6MBKWLDHVo .node circle,#mermaid-svg-Nq8dsL6MBKWLDHVo .node ellipse,#mermaid-svg-Nq8dsL6MBKWLDHVo .node polygon,#mermaid-svg-Nq8dsL6MBKWLDHVo .node path{fill:#ECECFF;stroke:#9370DB;stroke-width:1px;}#mermaid-svg-Nq8dsL6MBKWLDHVo .rough-node .label text,#mermaid-svg-Nq8dsL6MBKWLDHVo .node .label text,#mermaid-svg-Nq8dsL6MBKWLDHVo .image-shape .label,#mermaid-svg-Nq8dsL6MBKWLDHVo .icon-shape .label{text-anchor:middle;}#mermaid-svg-Nq8dsL6MBKWLDHVo .node .katex path{fill:#000;stroke:#000;stroke-width:1px;}#mermaid-svg-Nq8dsL6MBKWLDHVo .rough-node .label,#mermaid-svg-Nq8dsL6MBKWLDHVo .node .label,#mermaid-svg-Nq8dsL6MBKWLDHVo .image-shape .label,#mermaid-svg-Nq8dsL6MBKWLDHVo .icon-shape .label{text-align:center;}#mermaid-svg-Nq8dsL6MBKWLDHVo .node.clickable{cursor:pointer;}#mermaid-svg-Nq8dsL6MBKWLDHVo .root .anchor path{fill:#333333!important;stroke-width:0;stroke:#333333;}#mermaid-svg-Nq8dsL6MBKWLDHVo .arrowheadPath{fill:#333333;}#mermaid-svg-Nq8dsL6MBKWLDHVo .edgePath .path{stroke:#333333;stroke-width:2.0px;}#mermaid-svg-Nq8dsL6MBKWLDHVo .flowchart-link{stroke:#333333;fill:none;}#mermaid-svg-Nq8dsL6MBKWLDHVo .edgeLabel{background-color:rgba(232,232,232, 0.8);text-align:center;}#mermaid-svg-Nq8dsL6MBKWLDHVo .edgeLabel p{background-color:rgba(232,232,232, 0.8);}#mermaid-svg-Nq8dsL6MBKWLDHVo .edgeLabel rect{opacity:0.5;background-color:rgba(232,232,232, 0.8);fill:rgba(232,232,232, 0.8);}#mermaid-svg-Nq8dsL6MBKWLDHVo .labelBkg{background-color:rgba(232, 232, 232, 0.5);}#mermaid-svg-Nq8dsL6MBKWLDHVo .cluster rect{fill:#ffffde;stroke:#aaaa33;stroke-width:1px;}#mermaid-svg-Nq8dsL6MBKWLDHVo .cluster text{fill:#333;}#mermaid-svg-Nq8dsL6MBKWLDHVo .cluster span{color:#333;}#mermaid-svg-Nq8dsL6MBKWLDHVo div.mermaidTooltip{position:absolute;text-align:center;max-width:200px;padding:2px;font-family:"trebuchet ms",verdana,arial,sans-serif;font-size:12px;background:hsl(80, 100%, 96.2745098039%);border:1px solid #aaaa33;border-radius:2px;pointer-events:none;z-index:100;}#mermaid-svg-Nq8dsL6MBKWLDHVo .flowchartTitleText{text-anchor:middle;font-size:18px;fill:#333;}#mermaid-svg-Nq8dsL6MBKWLDHVo rect.text{fill:none;stroke-width:0;}#mermaid-svg-Nq8dsL6MBKWLDHVo .icon-shape,#mermaid-svg-Nq8dsL6MBKWLDHVo .image-shape{background-color:rgba(232,232,232, 0.8);text-align:center;}#mermaid-svg-Nq8dsL6MBKWLDHVo .icon-shape p,#mermaid-svg-Nq8dsL6MBKWLDHVo .image-shape p{background-color:rgba(232,232,232, 0.8);padding:2px;}#mermaid-svg-Nq8dsL6MBKWLDHVo .icon-shape .label rect,#mermaid-svg-Nq8dsL6MBKWLDHVo .image-shape .label rect{opacity:0.5;background-color:rgba(232,232,232, 0.8);fill:rgba(232,232,232, 0.8);}#mermaid-svg-Nq8dsL6MBKWLDHVo .label-icon{display:inline-block;height:1em;overflow:visible;vertical-align:-0.125em;}#mermaid-svg-Nq8dsL6MBKWLDHVo .node .label-icon path{fill:currentColor;stroke:revert;stroke-width:revert;}#mermaid-svg-Nq8dsL6MBKWLDHVo :root{--mermaid-font-family:"trebuchet ms",verdana,arial,sans-serif;} OpenAI 发现某些场景

需要更结构化的输出
Chat Completions

(2023年初)
Responses

(2024年末)

  • Chat Completions:最经典、最广泛------OpenAI 的"元老协议"
  • Responses:功能更丰富------支持工具调用、推理、结构化输出

原因二:设计理念不同

  • Chat Completions:极简主义------你发消息列表,我回一条消息。兼容性最好,几乎所有厂商都支持。
  • Responses:功能主义------支持"推理链"、"工具调用"、"多步执行"等复杂场景。
  • Anthropic Messages:独立生态------Anthropic 从自己的 Claude 模型出发设计,有自己的认证方式和字段命名。

原因三:认证方式不同

OpenAI 系协议用 Authorization: Bearer <key> 一个头搞定;Anthropic 用 x-api-key + anthropic-version 两个头,而且 max_tokens 是必填参数。

1.3 代理服务在架构中的角色

你可能还会有疑问:为什么不直接让前端调用上游大模型,而要搞一个代理?
#mermaid-svg-k9Tz8wNVr3MaLuAg{font-family:"trebuchet ms",verdana,arial,sans-serif;font-size:16px;fill:#333;}@keyframes edge-animation-frame{from{stroke-dashoffset:0;}}@keyframes dash{to{stroke-dashoffset:0;}}#mermaid-svg-k9Tz8wNVr3MaLuAg .edge-animation-slow{stroke-dasharray:9,5!important;stroke-dashoffset:900;animation:dash 50s linear infinite;stroke-linecap:round;}#mermaid-svg-k9Tz8wNVr3MaLuAg .edge-animation-fast{stroke-dasharray:9,5!important;stroke-dashoffset:900;animation:dash 20s linear infinite;stroke-linecap:round;}#mermaid-svg-k9Tz8wNVr3MaLuAg .error-icon{fill:#552222;}#mermaid-svg-k9Tz8wNVr3MaLuAg .error-text{fill:#552222;stroke:#552222;}#mermaid-svg-k9Tz8wNVr3MaLuAg .edge-thickness-normal{stroke-width:1px;}#mermaid-svg-k9Tz8wNVr3MaLuAg .edge-thickness-thick{stroke-width:3.5px;}#mermaid-svg-k9Tz8wNVr3MaLuAg .edge-pattern-solid{stroke-dasharray:0;}#mermaid-svg-k9Tz8wNVr3MaLuAg .edge-thickness-invisible{stroke-width:0;fill:none;}#mermaid-svg-k9Tz8wNVr3MaLuAg .edge-pattern-dashed{stroke-dasharray:3;}#mermaid-svg-k9Tz8wNVr3MaLuAg .edge-pattern-dotted{stroke-dasharray:2;}#mermaid-svg-k9Tz8wNVr3MaLuAg .marker{fill:#333333;stroke:#333333;}#mermaid-svg-k9Tz8wNVr3MaLuAg .marker.cross{stroke:#333333;}#mermaid-svg-k9Tz8wNVr3MaLuAg svg{font-family:"trebuchet ms",verdana,arial,sans-serif;font-size:16px;}#mermaid-svg-k9Tz8wNVr3MaLuAg p{margin:0;}#mermaid-svg-k9Tz8wNVr3MaLuAg .label{font-family:"trebuchet ms",verdana,arial,sans-serif;color:#333;}#mermaid-svg-k9Tz8wNVr3MaLuAg .cluster-label text{fill:#333;}#mermaid-svg-k9Tz8wNVr3MaLuAg .cluster-label span{color:#333;}#mermaid-svg-k9Tz8wNVr3MaLuAg .cluster-label span p{background-color:transparent;}#mermaid-svg-k9Tz8wNVr3MaLuAg .label text,#mermaid-svg-k9Tz8wNVr3MaLuAg span{fill:#333;color:#333;}#mermaid-svg-k9Tz8wNVr3MaLuAg .node rect,#mermaid-svg-k9Tz8wNVr3MaLuAg .node circle,#mermaid-svg-k9Tz8wNVr3MaLuAg .node ellipse,#mermaid-svg-k9Tz8wNVr3MaLuAg .node polygon,#mermaid-svg-k9Tz8wNVr3MaLuAg .node path{fill:#ECECFF;stroke:#9370DB;stroke-width:1px;}#mermaid-svg-k9Tz8wNVr3MaLuAg .rough-node .label text,#mermaid-svg-k9Tz8wNVr3MaLuAg .node .label text,#mermaid-svg-k9Tz8wNVr3MaLuAg .image-shape .label,#mermaid-svg-k9Tz8wNVr3MaLuAg .icon-shape .label{text-anchor:middle;}#mermaid-svg-k9Tz8wNVr3MaLuAg .node .katex path{fill:#000;stroke:#000;stroke-width:1px;}#mermaid-svg-k9Tz8wNVr3MaLuAg .rough-node .label,#mermaid-svg-k9Tz8wNVr3MaLuAg .node .label,#mermaid-svg-k9Tz8wNVr3MaLuAg .image-shape .label,#mermaid-svg-k9Tz8wNVr3MaLuAg .icon-shape .label{text-align:center;}#mermaid-svg-k9Tz8wNVr3MaLuAg .node.clickable{cursor:pointer;}#mermaid-svg-k9Tz8wNVr3MaLuAg .root .anchor path{fill:#333333!important;stroke-width:0;stroke:#333333;}#mermaid-svg-k9Tz8wNVr3MaLuAg .arrowheadPath{fill:#333333;}#mermaid-svg-k9Tz8wNVr3MaLuAg .edgePath .path{stroke:#333333;stroke-width:2.0px;}#mermaid-svg-k9Tz8wNVr3MaLuAg .flowchart-link{stroke:#333333;fill:none;}#mermaid-svg-k9Tz8wNVr3MaLuAg .edgeLabel{background-color:rgba(232,232,232, 0.8);text-align:center;}#mermaid-svg-k9Tz8wNVr3MaLuAg .edgeLabel p{background-color:rgba(232,232,232, 0.8);}#mermaid-svg-k9Tz8wNVr3MaLuAg .edgeLabel rect{opacity:0.5;background-color:rgba(232,232,232, 0.8);fill:rgba(232,232,232, 0.8);}#mermaid-svg-k9Tz8wNVr3MaLuAg .labelBkg{background-color:rgba(232, 232, 232, 0.5);}#mermaid-svg-k9Tz8wNVr3MaLuAg .cluster rect{fill:#ffffde;stroke:#aaaa33;stroke-width:1px;}#mermaid-svg-k9Tz8wNVr3MaLuAg .cluster text{fill:#333;}#mermaid-svg-k9Tz8wNVr3MaLuAg .cluster span{color:#333;}#mermaid-svg-k9Tz8wNVr3MaLuAg div.mermaidTooltip{position:absolute;text-align:center;max-width:200px;padding:2px;font-family:"trebuchet ms",verdana,arial,sans-serif;font-size:12px;background:hsl(80, 100%, 96.2745098039%);border:1px solid #aaaa33;border-radius:2px;pointer-events:none;z-index:100;}#mermaid-svg-k9Tz8wNVr3MaLuAg .flowchartTitleText{text-anchor:middle;font-size:18px;fill:#333;}#mermaid-svg-k9Tz8wNVr3MaLuAg rect.text{fill:none;stroke-width:0;}#mermaid-svg-k9Tz8wNVr3MaLuAg .icon-shape,#mermaid-svg-k9Tz8wNVr3MaLuAg .image-shape{background-color:rgba(232,232,232, 0.8);text-align:center;}#mermaid-svg-k9Tz8wNVr3MaLuAg .icon-shape p,#mermaid-svg-k9Tz8wNVr3MaLuAg .image-shape p{background-color:rgba(232,232,232, 0.8);padding:2px;}#mermaid-svg-k9Tz8wNVr3MaLuAg .icon-shape .label rect,#mermaid-svg-k9Tz8wNVr3MaLuAg .image-shape .label rect{opacity:0.5;background-color:rgba(232,232,232, 0.8);fill:rgba(232,232,232, 0.8);}#mermaid-svg-k9Tz8wNVr3MaLuAg .label-icon{display:inline-block;height:1em;overflow:visible;vertical-align:-0.125em;}#mermaid-svg-k9Tz8wNVr3MaLuAg .node .label-icon path{fill:currentColor;stroke:revert;stroke-width:revert;}#mermaid-svg-k9Tz8wNVr3MaLuAg :root{--mermaid-font-family:"trebuchet ms",verdana,arial,sans-serif;} 有代理
前端
代理服务
上游大模型

(API Key 安全封装)
无代理
前端
上游大模型

(直接暴露 API Key)

代理服务的价值:

价值 说明
API Key 安全 上游 API Key 存在代理服务端,前端永远不接触
协议统一 代理可以将不同协议的上游统一封装,前端只需对接一种格式
限流保护 代理可以控制调用频率,防止超出上游限额
日志审计 所有请求经过代理,便于记录和分析

本项目是一个**"原生透传"**代理------不做协议转换,前端按原始协议格式发送请求,代理原封不动转发到上游。这种方式最简单、最高效,也让前端可以自由选择最合适的协议。

1.4 一次 LLM 调用的完整链路

无论你选择哪种协议,一次 LLM 调用的基本流程都是一样的:
#mermaid-svg-9tp14a4cyQSwIqZX{font-family:"trebuchet ms",verdana,arial,sans-serif;font-size:16px;fill:#333;}@keyframes edge-animation-frame{from{stroke-dashoffset:0;}}@keyframes dash{to{stroke-dashoffset:0;}}#mermaid-svg-9tp14a4cyQSwIqZX .edge-animation-slow{stroke-dasharray:9,5!important;stroke-dashoffset:900;animation:dash 50s linear infinite;stroke-linecap:round;}#mermaid-svg-9tp14a4cyQSwIqZX .edge-animation-fast{stroke-dasharray:9,5!important;stroke-dashoffset:900;animation:dash 20s linear infinite;stroke-linecap:round;}#mermaid-svg-9tp14a4cyQSwIqZX .error-icon{fill:#552222;}#mermaid-svg-9tp14a4cyQSwIqZX .error-text{fill:#552222;stroke:#552222;}#mermaid-svg-9tp14a4cyQSwIqZX .edge-thickness-normal{stroke-width:1px;}#mermaid-svg-9tp14a4cyQSwIqZX .edge-thickness-thick{stroke-width:3.5px;}#mermaid-svg-9tp14a4cyQSwIqZX .edge-pattern-solid{stroke-dasharray:0;}#mermaid-svg-9tp14a4cyQSwIqZX .edge-thickness-invisible{stroke-width:0;fill:none;}#mermaid-svg-9tp14a4cyQSwIqZX .edge-pattern-dashed{stroke-dasharray:3;}#mermaid-svg-9tp14a4cyQSwIqZX .edge-pattern-dotted{stroke-dasharray:2;}#mermaid-svg-9tp14a4cyQSwIqZX .marker{fill:#333333;stroke:#333333;}#mermaid-svg-9tp14a4cyQSwIqZX .marker.cross{stroke:#333333;}#mermaid-svg-9tp14a4cyQSwIqZX svg{font-family:"trebuchet ms",verdana,arial,sans-serif;font-size:16px;}#mermaid-svg-9tp14a4cyQSwIqZX p{margin:0;}#mermaid-svg-9tp14a4cyQSwIqZX .label{font-family:"trebuchet ms",verdana,arial,sans-serif;color:#333;}#mermaid-svg-9tp14a4cyQSwIqZX .cluster-label text{fill:#333;}#mermaid-svg-9tp14a4cyQSwIqZX .cluster-label span{color:#333;}#mermaid-svg-9tp14a4cyQSwIqZX .cluster-label span p{background-color:transparent;}#mermaid-svg-9tp14a4cyQSwIqZX .label text,#mermaid-svg-9tp14a4cyQSwIqZX span{fill:#333;color:#333;}#mermaid-svg-9tp14a4cyQSwIqZX .node rect,#mermaid-svg-9tp14a4cyQSwIqZX .node circle,#mermaid-svg-9tp14a4cyQSwIqZX .node ellipse,#mermaid-svg-9tp14a4cyQSwIqZX .node polygon,#mermaid-svg-9tp14a4cyQSwIqZX .node path{fill:#ECECFF;stroke:#9370DB;stroke-width:1px;}#mermaid-svg-9tp14a4cyQSwIqZX .rough-node .label text,#mermaid-svg-9tp14a4cyQSwIqZX .node .label text,#mermaid-svg-9tp14a4cyQSwIqZX .image-shape .label,#mermaid-svg-9tp14a4cyQSwIqZX .icon-shape .label{text-anchor:middle;}#mermaid-svg-9tp14a4cyQSwIqZX .node .katex path{fill:#000;stroke:#000;stroke-width:1px;}#mermaid-svg-9tp14a4cyQSwIqZX .rough-node .label,#mermaid-svg-9tp14a4cyQSwIqZX .node .label,#mermaid-svg-9tp14a4cyQSwIqZX .image-shape .label,#mermaid-svg-9tp14a4cyQSwIqZX .icon-shape .label{text-align:center;}#mermaid-svg-9tp14a4cyQSwIqZX .node.clickable{cursor:pointer;}#mermaid-svg-9tp14a4cyQSwIqZX .root .anchor path{fill:#333333!important;stroke-width:0;stroke:#333333;}#mermaid-svg-9tp14a4cyQSwIqZX .arrowheadPath{fill:#333333;}#mermaid-svg-9tp14a4cyQSwIqZX .edgePath .path{stroke:#333333;stroke-width:2.0px;}#mermaid-svg-9tp14a4cyQSwIqZX .flowchart-link{stroke:#333333;fill:none;}#mermaid-svg-9tp14a4cyQSwIqZX .edgeLabel{background-color:rgba(232,232,232, 0.8);text-align:center;}#mermaid-svg-9tp14a4cyQSwIqZX .edgeLabel p{background-color:rgba(232,232,232, 0.8);}#mermaid-svg-9tp14a4cyQSwIqZX .edgeLabel rect{opacity:0.5;background-color:rgba(232,232,232, 0.8);fill:rgba(232,232,232, 0.8);}#mermaid-svg-9tp14a4cyQSwIqZX .labelBkg{background-color:rgba(232, 232, 232, 0.5);}#mermaid-svg-9tp14a4cyQSwIqZX .cluster rect{fill:#ffffde;stroke:#aaaa33;stroke-width:1px;}#mermaid-svg-9tp14a4cyQSwIqZX .cluster text{fill:#333;}#mermaid-svg-9tp14a4cyQSwIqZX .cluster span{color:#333;}#mermaid-svg-9tp14a4cyQSwIqZX div.mermaidTooltip{position:absolute;text-align:center;max-width:200px;padding:2px;font-family:"trebuchet ms",verdana,arial,sans-serif;font-size:12px;background:hsl(80, 100%, 96.2745098039%);border:1px solid #aaaa33;border-radius:2px;pointer-events:none;z-index:100;}#mermaid-svg-9tp14a4cyQSwIqZX .flowchartTitleText{text-anchor:middle;font-size:18px;fill:#333;}#mermaid-svg-9tp14a4cyQSwIqZX rect.text{fill:none;stroke-width:0;}#mermaid-svg-9tp14a4cyQSwIqZX .icon-shape,#mermaid-svg-9tp14a4cyQSwIqZX .image-shape{background-color:rgba(232,232,232, 0.8);text-align:center;}#mermaid-svg-9tp14a4cyQSwIqZX .icon-shape p,#mermaid-svg-9tp14a4cyQSwIqZX .image-shape p{background-color:rgba(232,232,232, 0.8);padding:2px;}#mermaid-svg-9tp14a4cyQSwIqZX .icon-shape .label rect,#mermaid-svg-9tp14a4cyQSwIqZX .image-shape .label rect{opacity:0.5;background-color:rgba(232,232,232, 0.8);fill:rgba(232,232,232, 0.8);}#mermaid-svg-9tp14a4cyQSwIqZX .label-icon{display:inline-block;height:1em;overflow:visible;vertical-align:-0.125em;}#mermaid-svg-9tp14a4cyQSwIqZX .node .label-icon path{fill:currentColor;stroke:revert;stroke-width:revert;}#mermaid-svg-9tp14a4cyQSwIqZX :root{--mermaid-font-family:"trebuchet ms",verdana,arial,sans-serif;} 1. 发送 HTTP POST

(携带模型名、消息内容)
2. 拼装认证头

(根据协议类型选择)
3. 转发请求到上游端点
4. 返回响应

(非流式:JSON / 流式:SSE)
5. 原样中继响应
6. 解析响应,渲染到页面
用户/前端
代理服务
上游 LLM 服务

衔接下一章:知道了"做什么",接下来要看"怎么做"。第二章将展示项目的整体架构,让你在动手写代码之前先建立全局视野。


第二章 项目架构全景

本章定位:在写任何一行代码之前,先建立全局视野------项目由哪些模块组成、每个模块的职责是什么、数据如何流动。本章结尾将引出"我们需要什么样的依赖",自然过渡到第三章的配置搭建。

2.1 整体架构图

#mermaid-svg-Niq7HnOdjGAANrXf{font-family:"trebuchet ms",verdana,arial,sans-serif;font-size:16px;fill:#333;}@keyframes edge-animation-frame{from{stroke-dashoffset:0;}}@keyframes dash{to{stroke-dashoffset:0;}}#mermaid-svg-Niq7HnOdjGAANrXf .edge-animation-slow{stroke-dasharray:9,5!important;stroke-dashoffset:900;animation:dash 50s linear infinite;stroke-linecap:round;}#mermaid-svg-Niq7HnOdjGAANrXf .edge-animation-fast{stroke-dasharray:9,5!important;stroke-dashoffset:900;animation:dash 20s linear infinite;stroke-linecap:round;}#mermaid-svg-Niq7HnOdjGAANrXf .error-icon{fill:#552222;}#mermaid-svg-Niq7HnOdjGAANrXf .error-text{fill:#552222;stroke:#552222;}#mermaid-svg-Niq7HnOdjGAANrXf .edge-thickness-normal{stroke-width:1px;}#mermaid-svg-Niq7HnOdjGAANrXf .edge-thickness-thick{stroke-width:3.5px;}#mermaid-svg-Niq7HnOdjGAANrXf .edge-pattern-solid{stroke-dasharray:0;}#mermaid-svg-Niq7HnOdjGAANrXf .edge-thickness-invisible{stroke-width:0;fill:none;}#mermaid-svg-Niq7HnOdjGAANrXf .edge-pattern-dashed{stroke-dasharray:3;}#mermaid-svg-Niq7HnOdjGAANrXf .edge-pattern-dotted{stroke-dasharray:2;}#mermaid-svg-Niq7HnOdjGAANrXf .marker{fill:#333333;stroke:#333333;}#mermaid-svg-Niq7HnOdjGAANrXf .marker.cross{stroke:#333333;}#mermaid-svg-Niq7HnOdjGAANrXf svg{font-family:"trebuchet ms",verdana,arial,sans-serif;font-size:16px;}#mermaid-svg-Niq7HnOdjGAANrXf p{margin:0;}#mermaid-svg-Niq7HnOdjGAANrXf .label{font-family:"trebuchet ms",verdana,arial,sans-serif;color:#333;}#mermaid-svg-Niq7HnOdjGAANrXf .cluster-label text{fill:#333;}#mermaid-svg-Niq7HnOdjGAANrXf .cluster-label span{color:#333;}#mermaid-svg-Niq7HnOdjGAANrXf .cluster-label span p{background-color:transparent;}#mermaid-svg-Niq7HnOdjGAANrXf .label text,#mermaid-svg-Niq7HnOdjGAANrXf span{fill:#333;color:#333;}#mermaid-svg-Niq7HnOdjGAANrXf .node rect,#mermaid-svg-Niq7HnOdjGAANrXf .node circle,#mermaid-svg-Niq7HnOdjGAANrXf .node ellipse,#mermaid-svg-Niq7HnOdjGAANrXf .node polygon,#mermaid-svg-Niq7HnOdjGAANrXf .node path{fill:#ECECFF;stroke:#9370DB;stroke-width:1px;}#mermaid-svg-Niq7HnOdjGAANrXf .rough-node .label text,#mermaid-svg-Niq7HnOdjGAANrXf .node .label text,#mermaid-svg-Niq7HnOdjGAANrXf .image-shape .label,#mermaid-svg-Niq7HnOdjGAANrXf .icon-shape .label{text-anchor:middle;}#mermaid-svg-Niq7HnOdjGAANrXf .node .katex path{fill:#000;stroke:#000;stroke-width:1px;}#mermaid-svg-Niq7HnOdjGAANrXf .rough-node .label,#mermaid-svg-Niq7HnOdjGAANrXf .node .label,#mermaid-svg-Niq7HnOdjGAANrXf .image-shape .label,#mermaid-svg-Niq7HnOdjGAANrXf .icon-shape .label{text-align:center;}#mermaid-svg-Niq7HnOdjGAANrXf .node.clickable{cursor:pointer;}#mermaid-svg-Niq7HnOdjGAANrXf .root .anchor path{fill:#333333!important;stroke-width:0;stroke:#333333;}#mermaid-svg-Niq7HnOdjGAANrXf .arrowheadPath{fill:#333333;}#mermaid-svg-Niq7HnOdjGAANrXf .edgePath .path{stroke:#333333;stroke-width:2.0px;}#mermaid-svg-Niq7HnOdjGAANrXf .flowchart-link{stroke:#333333;fill:none;}#mermaid-svg-Niq7HnOdjGAANrXf .edgeLabel{background-color:rgba(232,232,232, 0.8);text-align:center;}#mermaid-svg-Niq7HnOdjGAANrXf .edgeLabel p{background-color:rgba(232,232,232, 0.8);}#mermaid-svg-Niq7HnOdjGAANrXf .edgeLabel rect{opacity:0.5;background-color:rgba(232,232,232, 0.8);fill:rgba(232,232,232, 0.8);}#mermaid-svg-Niq7HnOdjGAANrXf .labelBkg{background-color:rgba(232, 232, 232, 0.5);}#mermaid-svg-Niq7HnOdjGAANrXf .cluster rect{fill:#ffffde;stroke:#aaaa33;stroke-width:1px;}#mermaid-svg-Niq7HnOdjGAANrXf .cluster text{fill:#333;}#mermaid-svg-Niq7HnOdjGAANrXf .cluster span{color:#333;}#mermaid-svg-Niq7HnOdjGAANrXf div.mermaidTooltip{position:absolute;text-align:center;max-width:200px;padding:2px;font-family:"trebuchet ms",verdana,arial,sans-serif;font-size:12px;background:hsl(80, 100%, 96.2745098039%);border:1px solid #aaaa33;border-radius:2px;pointer-events:none;z-index:100;}#mermaid-svg-Niq7HnOdjGAANrXf .flowchartTitleText{text-anchor:middle;font-size:18px;fill:#333;}#mermaid-svg-Niq7HnOdjGAANrXf rect.text{fill:none;stroke-width:0;}#mermaid-svg-Niq7HnOdjGAANrXf .icon-shape,#mermaid-svg-Niq7HnOdjGAANrXf .image-shape{background-color:rgba(232,232,232, 0.8);text-align:center;}#mermaid-svg-Niq7HnOdjGAANrXf .icon-shape p,#mermaid-svg-Niq7HnOdjGAANrXf .image-shape p{background-color:rgba(232,232,232, 0.8);padding:2px;}#mermaid-svg-Niq7HnOdjGAANrXf .icon-shape .label rect,#mermaid-svg-Niq7HnOdjGAANrXf .image-shape .label rect{opacity:0.5;background-color:rgba(232,232,232, 0.8);fill:rgba(232,232,232, 0.8);}#mermaid-svg-Niq7HnOdjGAANrXf .label-icon{display:inline-block;height:1em;overflow:visible;vertical-align:-0.125em;}#mermaid-svg-Niq7HnOdjGAANrXf .node .label-icon path{fill:currentColor;stroke:revert;stroke-width:revert;}#mermaid-svg-Niq7HnOdjGAANrXf :root{--mermaid-font-family:"trebuchet ms",verdana,arial,sans-serif;} 上游 LLM 服务
后端 (Spring Boot)
前端
异常层
配置层
服务层
控制器层
HTTP POST
HTTP POST
HTTP POST
RestClient(非流式)

WebClient(流式)
RestClient(非流式)

WebClient(流式)
RestClient(非流式)

WebClient(流式)
注入
注入
注入
注入
注入
注入
抛出
抛出
抛出
捕获
HTML 页面

index.html
app.js

协议拼装 + 响应解析
OpenAiCompletions

Controller
OpenAiResponses

Controller
AnthropicMessages

Controller
OpenAiCompletions

Service
OpenAiResponses

Service
AnthropicMessages

Service
HttpClientConfig

RestClient + WebClient
UpstreamConfig

上游地址/密钥/模型
ApiException
GlobalExceptionHandler
三种协议端点

/chat/completions

/responses

/messages

2.2 目录结构

复制代码
rest/
├── pom.xml                                         # Maven 依赖管理
├── src/main/
│   ├── java/com/lihaozhe/
│   │   ├── RestApplication.java                    # 启动入口
│   │   ├── config/
│   │   │   ├── HttpClientConfig.java               # HTTP 客户端 Bean 配置
│   │   │   └── UpstreamConfig.java                 # 上游服务配置
│   │   ├── controller/
│   │   │   ├── OpenAiCompletionsController.java
│   │   │   ├── OpenAiResponsesController.java
│   │   │   └── AnthropicMessagesController.java
│   │   ├── service/
│   │   │   ├── OpenAiCompletionsService.java
│   │   │   ├── OpenAiResponsesService.java
│   │   │   └── AnthropicMessagesService.java
│   │   └── exception/
│   │       ├── ApiException.java
│   │       └── GlobalExceptionHandler.java
│   └── resources/
│       ├── application.yaml                        # 配置文件
│       └── static/                                 # 前端静态资源
│           ├── index.html
│           ├── css/index.css
│           └── js/app.js

2.3 数据流动图

#mermaid-svg-yreTX1JaANVUnInD{font-family:"trebuchet ms",verdana,arial,sans-serif;font-size:16px;fill:#333;}@keyframes edge-animation-frame{from{stroke-dashoffset:0;}}@keyframes dash{to{stroke-dashoffset:0;}}#mermaid-svg-yreTX1JaANVUnInD .edge-animation-slow{stroke-dasharray:9,5!important;stroke-dashoffset:900;animation:dash 50s linear infinite;stroke-linecap:round;}#mermaid-svg-yreTX1JaANVUnInD .edge-animation-fast{stroke-dasharray:9,5!important;stroke-dashoffset:900;animation:dash 20s linear infinite;stroke-linecap:round;}#mermaid-svg-yreTX1JaANVUnInD .error-icon{fill:#552222;}#mermaid-svg-yreTX1JaANVUnInD .error-text{fill:#552222;stroke:#552222;}#mermaid-svg-yreTX1JaANVUnInD .edge-thickness-normal{stroke-width:1px;}#mermaid-svg-yreTX1JaANVUnInD .edge-thickness-thick{stroke-width:3.5px;}#mermaid-svg-yreTX1JaANVUnInD .edge-pattern-solid{stroke-dasharray:0;}#mermaid-svg-yreTX1JaANVUnInD .edge-thickness-invisible{stroke-width:0;fill:none;}#mermaid-svg-yreTX1JaANVUnInD .edge-pattern-dashed{stroke-dasharray:3;}#mermaid-svg-yreTX1JaANVUnInD .edge-pattern-dotted{stroke-dasharray:2;}#mermaid-svg-yreTX1JaANVUnInD .marker{fill:#333333;stroke:#333333;}#mermaid-svg-yreTX1JaANVUnInD .marker.cross{stroke:#333333;}#mermaid-svg-yreTX1JaANVUnInD svg{font-family:"trebuchet ms",verdana,arial,sans-serif;font-size:16px;}#mermaid-svg-yreTX1JaANVUnInD p{margin:0;}#mermaid-svg-yreTX1JaANVUnInD .label{font-family:"trebuchet ms",verdana,arial,sans-serif;color:#333;}#mermaid-svg-yreTX1JaANVUnInD .cluster-label text{fill:#333;}#mermaid-svg-yreTX1JaANVUnInD .cluster-label span{color:#333;}#mermaid-svg-yreTX1JaANVUnInD .cluster-label span p{background-color:transparent;}#mermaid-svg-yreTX1JaANVUnInD .label text,#mermaid-svg-yreTX1JaANVUnInD span{fill:#333;color:#333;}#mermaid-svg-yreTX1JaANVUnInD .node rect,#mermaid-svg-yreTX1JaANVUnInD .node circle,#mermaid-svg-yreTX1JaANVUnInD .node ellipse,#mermaid-svg-yreTX1JaANVUnInD .node polygon,#mermaid-svg-yreTX1JaANVUnInD .node path{fill:#ECECFF;stroke:#9370DB;stroke-width:1px;}#mermaid-svg-yreTX1JaANVUnInD .rough-node .label text,#mermaid-svg-yreTX1JaANVUnInD .node .label text,#mermaid-svg-yreTX1JaANVUnInD .image-shape .label,#mermaid-svg-yreTX1JaANVUnInD .icon-shape .label{text-anchor:middle;}#mermaid-svg-yreTX1JaANVUnInD .node .katex path{fill:#000;stroke:#000;stroke-width:1px;}#mermaid-svg-yreTX1JaANVUnInD .rough-node .label,#mermaid-svg-yreTX1JaANVUnInD .node .label,#mermaid-svg-yreTX1JaANVUnInD .image-shape .label,#mermaid-svg-yreTX1JaANVUnInD .icon-shape .label{text-align:center;}#mermaid-svg-yreTX1JaANVUnInD .node.clickable{cursor:pointer;}#mermaid-svg-yreTX1JaANVUnInD .root .anchor path{fill:#333333!important;stroke-width:0;stroke:#333333;}#mermaid-svg-yreTX1JaANVUnInD .arrowheadPath{fill:#333333;}#mermaid-svg-yreTX1JaANVUnInD .edgePath .path{stroke:#333333;stroke-width:2.0px;}#mermaid-svg-yreTX1JaANVUnInD .flowchart-link{stroke:#333333;fill:none;}#mermaid-svg-yreTX1JaANVUnInD .edgeLabel{background-color:rgba(232,232,232, 0.8);text-align:center;}#mermaid-svg-yreTX1JaANVUnInD .edgeLabel p{background-color:rgba(232,232,232, 0.8);}#mermaid-svg-yreTX1JaANVUnInD .edgeLabel rect{opacity:0.5;background-color:rgba(232,232,232, 0.8);fill:rgba(232,232,232, 0.8);}#mermaid-svg-yreTX1JaANVUnInD .labelBkg{background-color:rgba(232, 232, 232, 0.5);}#mermaid-svg-yreTX1JaANVUnInD .cluster rect{fill:#ffffde;stroke:#aaaa33;stroke-width:1px;}#mermaid-svg-yreTX1JaANVUnInD .cluster text{fill:#333;}#mermaid-svg-yreTX1JaANVUnInD .cluster span{color:#333;}#mermaid-svg-yreTX1JaANVUnInD div.mermaidTooltip{position:absolute;text-align:center;max-width:200px;padding:2px;font-family:"trebuchet ms",verdana,arial,sans-serif;font-size:12px;background:hsl(80, 100%, 96.2745098039%);border:1px solid #aaaa33;border-radius:2px;pointer-events:none;z-index:100;}#mermaid-svg-yreTX1JaANVUnInD .flowchartTitleText{text-anchor:middle;font-size:18px;fill:#333;}#mermaid-svg-yreTX1JaANVUnInD rect.text{fill:none;stroke-width:0;}#mermaid-svg-yreTX1JaANVUnInD .icon-shape,#mermaid-svg-yreTX1JaANVUnInD .image-shape{background-color:rgba(232,232,232, 0.8);text-align:center;}#mermaid-svg-yreTX1JaANVUnInD .icon-shape p,#mermaid-svg-yreTX1JaANVUnInD .image-shape p{background-color:rgba(232,232,232, 0.8);padding:2px;}#mermaid-svg-yreTX1JaANVUnInD .icon-shape .label rect,#mermaid-svg-yreTX1JaANVUnInD .image-shape .label rect{opacity:0.5;background-color:rgba(232,232,232, 0.8);fill:rgba(232,232,232, 0.8);}#mermaid-svg-yreTX1JaANVUnInD .label-icon{display:inline-block;height:1em;overflow:visible;vertical-align:-0.125em;}#mermaid-svg-yreTX1JaANVUnInD .node .label-icon path{fill:currentColor;stroke:revert;stroke-width:revert;}#mermaid-svg-yreTX1JaANVUnInD :root{--mermaid-font-family:"trebuchet ms",verdana,arial,sans-serif;} JSON 请求体
Map
RestClient / WebClient
JSON 响应 / SSE 事件流
原样返回
JSON 响应
extractText()

/ extractDelta()
用户输入

'介绍吉林省文旅资源'
app.js

buildBody()
Controller
Service
上游 LLM
渲染到页面

2.4 核心设计原则

  1. 原生透传:Service 层不做任何协议转换,前端发什么格式,上游就收什么格式
  2. Map 穿透 :请求体类型统一为 Map<String, Object>,前端可以自由拼装任意字段
  3. 双客户端:RestClient(同步、非流式)和 WebClient(响应式、流式)各司其职
  4. 错误收敛 :所有异常由 GlobalExceptionHandler 统一捕获,返回标准 JSON

衔接下一章 :架构清晰了,下一步是"如何搭建项目骨架"。下一章将从 pom.xmlapplication.yaml 开始,一步步配置好项目的基础设施。


第三章 项目骨架搭建------依赖、配置与启动

本章定位:从零搭建一个可运行的 Spring Boot 项目。你将了解每个依赖的作用、配置文件的含义,以及如何把上游 LLM 服务的地址和密钥配置进去。本章结尾,你将拥有一个可以启动但还没写任何业务逻辑的空项目。

3.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>4.1.1</version>
        <relativePath/>
    </parent>

    <groupId>com.lihaozhe</groupId>
    <artifactId>llm-protocol-proxy</artifactId>
    <version>0.0.1</version>
    <name>llm-protocol-proxy</name>
    <description>LLM API 协议代理演示 --- 原生透传三种协议</description>

    <properties>
        <java.version>25</java.version>
    </properties>

    <dependencies>
        <!-- Spring MVC:提供 @RestController、@PostMapping 等 Web 能力 -->
        <dependency>
            <groupId>org.springframework.boot</groupId>
            <artifactId>spring-boot-starter-webmvc</artifactId>
        </dependency>

        <!-- Spring WebFlux:提供 WebClient + Flux/Mono 响应式能力 -->
        <dependency>
            <groupId>org.springframework.boot</groupId>
            <artifactId>spring-boot-starter-webflux</artifactId>
        </dependency>

        <!-- Lombok:简化 Java 样板代码(getter/setter/构造器等) -->
        <dependency>
            <groupId>org.projectlombok</groupId>
            <artifactId>lombok</artifactId>
            <optional>true</optional>
        </dependency>
    </dependencies>

    <build>
        <plugins>
            <plugin>
                <groupId>org.springframework.boot</groupId>
                <artifactId>spring-boot-maven-plugin</artifactId>
            </plugin>
            <plugin>
                <groupId>org.apache.maven.plugins</groupId>
                <artifactId>maven-compiler-plugin</artifactId>
                <executions>
                    <execution>
                        <id>default-compile</id>
                        <phase>compile</phase>
                        <goals>
                            <goal>compile</goal>
                        </goals>
                        <configuration>
                            <annotationProcessorPaths>
                                <path>
                                    <groupId>org.projectlombok</groupId>
                                    <artifactId>lombok</artifactId>
                                </path>
                                <path>
                                    <groupId>org.springframework.boot</groupId>
                                    <artifactId>spring-boot-configuration-processor</artifactId>
                                </path>
                            </annotationProcessorPaths>
                        </configuration>
                    </execution>
                </executions>
            </plugin>
        </plugins>
    </build>
</project>

依赖选型说明

依赖 作用 为什么选它
spring-boot-starter-webmvc 提供 Spring MVC 框架 @RestController 暴露 HTTP 端点
spring-boot-starter-webflux 提供 WebClient + Reactor 响应式 流式 SSE 场景需要非阻塞 IO
lombok 减少样板代码 @Slf4j@Getter@RequiredArgsConstructor

注解处理器说明

  • lombok:编译期生成 getter/setter/构造器等样板代码
  • spring-boot-configuration-processor:编译期为 @ConfigurationProperties 类生成元数据,IDE 自动补全配置项

3.2 application.yaml------配置文件

yaml 复制代码
# =============================================================================
# Spring Boot 配置文件
# =============================================================================
# 项目: LLM API 代理演示
# 架构: 协议原生透传 --- 上游提供三种协议端点, 本代理按协议选择端点原样转发
#       (路径与认证头封装在各协议 Service 中, 不做任何格式转换)
# =============================================================================

spring:
  application:
    name: rest

# =============================================================================
# 上游 LLM 服务配置(代理实际转发的目标)
# =============================================================================
# 上游同时提供三种协议的原生端点:
#   /chat/completions  (OpenAI Chat Completions)
#   /responses         (OpenAI Responses)
#   /messages          (Anthropic Messages)
# 本代理不做协议转换, 按协议选择对应端点原样透传
# (路径拼接与认证头见各协议 Service: OpenAiCompletionsService /
#  OpenAiResponsesService / AnthropicMessagesService)
# =============================================================================
upstream:
  # 上游 BASE_URL(三种协议端点共用, 由代理提供)
  base-url: http://127.0.0.1:8000/v1
  # 上游 API Key(代理提供的 proxy_sk_xxx)
  api-key: proxy_sk_57466cc548d79e270a3924f4b562ebf258bb950703235e30fe3727d33a4aab3e
  # 调用大模型的名称(当前 model 由前端请求体指定, 此项为预留)
  model: mimo-v2.5
  # Anthropic 协议必填参数(协议规范要求, 更换上游后依然必填);
  # OpenAI 系协议不要求此参数。仅当客户端调用 Anthropic 协议且未传 max_tokens 时作兜底
  max-tokens: 8192

# =============================================================================
# 客户端鉴权配置(客户端访问本服务时的 API Key)
# =============================================================================
# 当前未启用鉴权(任何人都可访问),如需启用可取消注释并实现认证 Filter
# =============================================================================
# auth:
#   api-keys:
#     - sk-test-12345

配置项解读

  • base-url:上游服务的地址。注意是 /v1 结尾,代码中会根据协议类型拼接不同路径(如 /chat/completions/responses/messages)。
  • api-key:上游 API Key,由代理服务安全保管。前端请求时不携带任何密钥。
  • model:预留字段。当前模型名由前端请求体写死(mimo-v2.5),修改此项不会影响请求,仅作服务端配置的预留入口。
  • max-tokens:仅用于 Anthropic 协议的兜底值(因为 Anthropic 协议要求 max_tokens 必填),客户端已传时不覆盖。

3.3 配置属性类------UpstreamConfig

如何让 YAML 配置在 Java 代码中可读可调用?Spring Boot 提供了 @ConfigurationProperties 机制:

java 复制代码
package com.lihaozhe.config;

import lombok.Getter;
import lombok.Setter;
import org.springframework.boot.context.properties.ConfigurationProperties;
import org.springframework.context.annotation.Configuration;

/**
 * 上游 LLM 服务配置
 * <p>
 * 上游同时提供三种协议的原生端点(OpenAI Chat Completions /
 * OpenAI Responses / Anthropic Messages), 本代理按协议选择对应端点
 * <b>原样透传</b>, 不做任何格式转换。更换上游时只需修改本配置,
 * 代码零改动(前提: 新上游同样支持这三种标准协议)。
 *
 * @author 李昊哲
 * @since 1.0.0
 */
@Setter
@Getter
@Configuration
@ConfigurationProperties(prefix = "upstream")
public class UpstreamConfig {

    /** 上游 BASE_URL(末尾不含端点路径, 由各协议 Service 拼接) */
    private String baseUrl;

    /** 上游 API Key (服务端秘密,代理提供的 proxy_sk_xxx) */
    private String apiKey;

    /** 调用大模型的名称(如 mimo-v2.5); 当前 model 由前端请求体指定, 本字段为预留 */
    private String model = "mimo-v2.5";

    /**
     * Anthropic 协议 max_tokens 缺省时的兜底值
     * <p>
     * max_tokens 是 Anthropic 协议的<b>必填参数</b>(协议规范要求,
     * 更换上游后依然必填), 客户端遗漏时用此默认值兜底;
     * OpenAI 系协议(Chat Completions / Responses)不要求此参数, 可选。
     */
    private Integer maxTokens = 8192;

}

初学者说明------本类用到的方法与参数

注解 / 成员 作用 初学者要点
@Configuration 声明"这是一个配置类",Spring 启动时会实例化并管理它 没有它,@ConfigurationProperties 不生效
@ConfigurationProperties(prefix = "upstream") 把 application.yaml 中 upstream.* 的值自动绑定到同名字段 upstream.base-urlbaseUrl 字段(YAML 的 - 会转成驼峰)
@Setter / @Getter(Lombok) 编译期自动生成所有字段的 setXxx() / getXxx() 方法 绑定机制需要 setter 赋值,代码里只读,所以用的是 getter
getBaseUrl() / getApiKey() / getMaxTokens() Lombok 生成的 getter Service 中 config.getBaseUrl() 拿到的就是 yaml 里填的值
private Integer maxTokens = 8192 字段默认值 yaml 未配置该项时用 8192 兜底,配置了则被覆盖

3.4 启动类

java 复制代码
package com.lihaozhe;

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

/**
 * 应用启动入口
 *
 * @author 李昊哲
 * @since 1.0.0
 */
@SpringBootApplication
public class RestApplication {

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

}

运行 RestApplication.main() 即可启动 Spring Boot 项目,默认监听 8080 端口。

衔接下一章:项目骨架搭好了,配置也填好了。但要真正调用上游 LLM 服务,我们还需要"HTTP 客户端"------下一章将讲解 RestClient 和 WebClient 的选型逻辑与配置细节。


第四章 HTTP 客户端------RestClient 与 WebClient 的选型与配置

本章定位:理解为什么需要两种 HTTP 客户端、它们各自适合什么场景、如何在 Spring Boot 中配置。本章将从同步 vs 异步的理论出发,自然引出"非流式用 RestClient、流式用 WebClient"的设计决策,为下一章的 Service 层实现做好技术准备。

4.1 同步 vs 异步:一张图理清

#mermaid-svg-kzKzWdHOT8rlTaEC{font-family:"trebuchet ms",verdana,arial,sans-serif;font-size:16px;fill:#333;}@keyframes edge-animation-frame{from{stroke-dashoffset:0;}}@keyframes dash{to{stroke-dashoffset:0;}}#mermaid-svg-kzKzWdHOT8rlTaEC .edge-animation-slow{stroke-dasharray:9,5!important;stroke-dashoffset:900;animation:dash 50s linear infinite;stroke-linecap:round;}#mermaid-svg-kzKzWdHOT8rlTaEC .edge-animation-fast{stroke-dasharray:9,5!important;stroke-dashoffset:900;animation:dash 20s linear infinite;stroke-linecap:round;}#mermaid-svg-kzKzWdHOT8rlTaEC .error-icon{fill:#552222;}#mermaid-svg-kzKzWdHOT8rlTaEC .error-text{fill:#552222;stroke:#552222;}#mermaid-svg-kzKzWdHOT8rlTaEC .edge-thickness-normal{stroke-width:1px;}#mermaid-svg-kzKzWdHOT8rlTaEC .edge-thickness-thick{stroke-width:3.5px;}#mermaid-svg-kzKzWdHOT8rlTaEC .edge-pattern-solid{stroke-dasharray:0;}#mermaid-svg-kzKzWdHOT8rlTaEC .edge-thickness-invisible{stroke-width:0;fill:none;}#mermaid-svg-kzKzWdHOT8rlTaEC .edge-pattern-dashed{stroke-dasharray:3;}#mermaid-svg-kzKzWdHOT8rlTaEC .edge-pattern-dotted{stroke-dasharray:2;}#mermaid-svg-kzKzWdHOT8rlTaEC .marker{fill:#333333;stroke:#333333;}#mermaid-svg-kzKzWdHOT8rlTaEC .marker.cross{stroke:#333333;}#mermaid-svg-kzKzWdHOT8rlTaEC svg{font-family:"trebuchet ms",verdana,arial,sans-serif;font-size:16px;}#mermaid-svg-kzKzWdHOT8rlTaEC p{margin:0;}#mermaid-svg-kzKzWdHOT8rlTaEC .label{font-family:"trebuchet ms",verdana,arial,sans-serif;color:#333;}#mermaid-svg-kzKzWdHOT8rlTaEC .cluster-label text{fill:#333;}#mermaid-svg-kzKzWdHOT8rlTaEC .cluster-label span{color:#333;}#mermaid-svg-kzKzWdHOT8rlTaEC .cluster-label span p{background-color:transparent;}#mermaid-svg-kzKzWdHOT8rlTaEC .label text,#mermaid-svg-kzKzWdHOT8rlTaEC span{fill:#333;color:#333;}#mermaid-svg-kzKzWdHOT8rlTaEC .node rect,#mermaid-svg-kzKzWdHOT8rlTaEC .node circle,#mermaid-svg-kzKzWdHOT8rlTaEC .node ellipse,#mermaid-svg-kzKzWdHOT8rlTaEC .node polygon,#mermaid-svg-kzKzWdHOT8rlTaEC .node path{fill:#ECECFF;stroke:#9370DB;stroke-width:1px;}#mermaid-svg-kzKzWdHOT8rlTaEC .rough-node .label text,#mermaid-svg-kzKzWdHOT8rlTaEC .node .label text,#mermaid-svg-kzKzWdHOT8rlTaEC .image-shape .label,#mermaid-svg-kzKzWdHOT8rlTaEC .icon-shape .label{text-anchor:middle;}#mermaid-svg-kzKzWdHOT8rlTaEC .node .katex path{fill:#000;stroke:#000;stroke-width:1px;}#mermaid-svg-kzKzWdHOT8rlTaEC .rough-node .label,#mermaid-svg-kzKzWdHOT8rlTaEC .node .label,#mermaid-svg-kzKzWdHOT8rlTaEC .image-shape .label,#mermaid-svg-kzKzWdHOT8rlTaEC .icon-shape .label{text-align:center;}#mermaid-svg-kzKzWdHOT8rlTaEC .node.clickable{cursor:pointer;}#mermaid-svg-kzKzWdHOT8rlTaEC .root .anchor path{fill:#333333!important;stroke-width:0;stroke:#333333;}#mermaid-svg-kzKzWdHOT8rlTaEC .arrowheadPath{fill:#333333;}#mermaid-svg-kzKzWdHOT8rlTaEC .edgePath .path{stroke:#333333;stroke-width:2.0px;}#mermaid-svg-kzKzWdHOT8rlTaEC .flowchart-link{stroke:#333333;fill:none;}#mermaid-svg-kzKzWdHOT8rlTaEC .edgeLabel{background-color:rgba(232,232,232, 0.8);text-align:center;}#mermaid-svg-kzKzWdHOT8rlTaEC .edgeLabel p{background-color:rgba(232,232,232, 0.8);}#mermaid-svg-kzKzWdHOT8rlTaEC .edgeLabel rect{opacity:0.5;background-color:rgba(232,232,232, 0.8);fill:rgba(232,232,232, 0.8);}#mermaid-svg-kzKzWdHOT8rlTaEC .labelBkg{background-color:rgba(232, 232, 232, 0.5);}#mermaid-svg-kzKzWdHOT8rlTaEC .cluster rect{fill:#ffffde;stroke:#aaaa33;stroke-width:1px;}#mermaid-svg-kzKzWdHOT8rlTaEC .cluster text{fill:#333;}#mermaid-svg-kzKzWdHOT8rlTaEC .cluster span{color:#333;}#mermaid-svg-kzKzWdHOT8rlTaEC div.mermaidTooltip{position:absolute;text-align:center;max-width:200px;padding:2px;font-family:"trebuchet ms",verdana,arial,sans-serif;font-size:12px;background:hsl(80, 100%, 96.2745098039%);border:1px solid #aaaa33;border-radius:2px;pointer-events:none;z-index:100;}#mermaid-svg-kzKzWdHOT8rlTaEC .flowchartTitleText{text-anchor:middle;font-size:18px;fill:#333;}#mermaid-svg-kzKzWdHOT8rlTaEC rect.text{fill:none;stroke-width:0;}#mermaid-svg-kzKzWdHOT8rlTaEC .icon-shape,#mermaid-svg-kzKzWdHOT8rlTaEC .image-shape{background-color:rgba(232,232,232, 0.8);text-align:center;}#mermaid-svg-kzKzWdHOT8rlTaEC .icon-shape p,#mermaid-svg-kzKzWdHOT8rlTaEC .image-shape p{background-color:rgba(232,232,232, 0.8);padding:2px;}#mermaid-svg-kzKzWdHOT8rlTaEC .icon-shape .label rect,#mermaid-svg-kzKzWdHOT8rlTaEC .image-shape .label rect{opacity:0.5;background-color:rgba(232,232,232, 0.8);fill:rgba(232,232,232, 0.8);}#mermaid-svg-kzKzWdHOT8rlTaEC .label-icon{display:inline-block;height:1em;overflow:visible;vertical-align:-0.125em;}#mermaid-svg-kzKzWdHOT8rlTaEC .node .label-icon path{fill:currentColor;stroke:revert;stroke-width:revert;}#mermaid-svg-kzKzWdHOT8rlTaEC :root{--mermaid-font-family:"trebuchet ms",verdana,arial,sans-serif;} 非流式

(等完整响应)
流式 SSE

(逐帧推送增量)
客户端发出请求
请求类型?
RestClient

阻塞等待

一次拿到完整 JSON
WebClient

非阻塞

持续接收事件流
响应体:完整 JSON

{choices:{message:{content:'...'}}}
SSE 事件流:

event: xxx

data: {...}

类比理解

  • RestClient 像"快递包裹"------你下单后等着,快递员一次把包裹送到你手里(同步等待完整响应)。
  • WebClient 像"水流"------水从管道里持续流出,你一边接一边用(非阻塞接收增量数据)。

4.2 为什么 LLM 场景特别需要两种客户端

场景 客户端选择 原因
非流式对话 RestClient 等待完整响应,逻辑简单,restClient.post().body().retrieve().body(String.class) 链式调用一行接一行
流式对话 WebClient SSE 是长连接,可能持续几十秒甚至几分钟,阻塞线程不现实;WebClient 基于 Reactor Netty 的非阻塞 IO

4.3 RestClient 配置详解

java 复制代码
package com.lihaozhe.config;

import io.netty.channel.ChannelOption;
import io.netty.handler.timeout.ReadTimeoutHandler;
import io.netty.handler.timeout.WriteTimeoutHandler;
import lombok.extern.slf4j.Slf4j;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.http.client.SimpleClientHttpRequestFactory;
import org.springframework.web.client.RestClient;
import org.springframework.web.reactive.function.client.WebClient;
import reactor.netty.http.client.HttpClient;

import java.time.Duration;
import java.util.concurrent.TimeUnit;

/**
 * 统一 HTTP 客户端 Bean 配置
 *
 * <h2>设计目标</h2>
 * 将 RestClient 和 WebClient 注册为 Spring Bean,供所有 Service 复用。
 *
 * <h2>超时策略</h2>
 * <pre>
 * 同步 (RestClient):
 * - 连接超时: 5s (TCP 握手)
 * - 读取超时: 60s (LLM 完整响应可能慢)
 *
 * 流式 (WebClient + Netty):
 * - 连接超时: 5s
 * - 整体响应超时: 120s
 * - 读空闲超时: 120s (SSE 心跳)
 * - 写空闲超时: 60s
 * </pre>
 *
 * <p>WebClient 超时比 RestClient 更长,因为流式响应可能持续数分钟,
 * 不能用单一 read-timeout 限制。
 *
 * @author 李昊哲
 * @since 1.0.0
 */
@Slf4j
@Configuration
public class HttpClientConfig {

    /**
     * 同步 HTTP 客户端(用于非流式 LLM 调用)
     *
     * <p>底层使用 {@link SimpleClientHttpRequestFactory} ------ JDK 自带的
     * {@link java.net.http.HttpClient} 封装,无连接池(每次请求新建 TCP 连接)。
     *
     * <p><b>适用场景</b>: 单次 LLM 对话(非流式)
     *
     * @return 配置好超时参数的 RestClient 实例
     */
    @Bean
    public RestClient restClient() {
        // SimpleClientHttpRequestFactory: 阻塞式 IO,使用 JDK HttpClient
        SimpleClientHttpRequestFactory factory = new SimpleClientHttpRequestFactory();
        // 连接阶段超时(单位:毫秒): TCP 三次握手最迟 5s
        factory.setConnectTimeout((int) Duration.ofSeconds(5).toMillis());
        // 读取阶段超时: 从服务端读取完整响应的最迟时长
        // 设为 60s 因为 LLM 长回答可能耗时数十秒
        factory.setReadTimeout((int) Duration.ofSeconds(60).toMillis());

        return RestClient.builder()
                .requestFactory(factory)
                .build();
    }

    /**
     * 响应式 HTTP 客户端(用于流式 LLM 调用)
     *
     * <p>底层使用 Netty 的 {@link HttpClient}。WebClient 自身只是上层 API,
     * 真正的 IO 由 Reactor Netty 完成。
     *
     * <p><b>适用场景</b>: SSE 流式响应,需要非阻塞 IO + 背压
     *
     * @return 配置好超时参数的 WebClient 实例
     */
    @Bean
    public WebClient webClient() {
        // Reactor Netty 客户端配置
        HttpClient httpClient = HttpClient.create()
                // ChannelOption.CONNECT_TIMEOUT_MILLIS: TCP 连接超时
                .option(ChannelOption.CONNECT_TIMEOUT_MILLIS, 5000)
                // responseTimeout: 整体响应超时(从请求发起到最后一个字节到达)
                .responseTimeout(Duration.ofSeconds(120))
                // doOnConnected: 连接建立后添加 Netty 处理器
                .doOnConnected(conn ->
                    // ReadTimeoutHandler: 读空闲超时(两个字节之间间隔)
                    // 流式响应会持续推送,这里设 120s 容忍空闲
                    conn.addHandlerLast(new ReadTimeoutHandler(120, TimeUnit.SECONDS))
                    // WriteTimeoutHandler: 写空闲超时
                    .addHandlerLast(new WriteTimeoutHandler(60, TimeUnit.SECONDS)));

        // 将 Netty 客户端封装为 Spring WebClient
        return WebClient.builder()
                .clientConnector(
                    new org.springframework.http.client.reactive.ReactorClientHttpConnector(httpClient))
                .build();
    }
}

两个超时的含义

  • connectTimeout(5s):TCP 三次握手的最长等待时间。如果上游服务不可用,5s 后立即报错。
  • readTimeout(60s):从连接建立后,等待上游返回完整响应的最长时长。LLM 生成长文本可能需要几十秒。

初学者说明------本类用到的方法与参数

方法 / 参数 作用 初学者要点
@Configuration 配置类注解,启动时执行 见 3.3 节说明
@Bean 标在方法上:把方法的返回值注册为 Spring 容器里的一个对象(Bean) 之后任何类在构造器里声明该类型参数,Spring 就会把这个实例"注入"进来
SimpleClientHttpRequestFactory RestClient 底层的"请求工厂",负责真正发 HTTP 用它设置超时;底层是 JDK 自带的 HttpClient
setConnectTimeout(毫秒) TCP 连接阶段超时 Duration.ofSeconds(5).toMillis() 把 5 秒换算成 5000 毫秒;(int) 强转是因为 setter 只收 int
setReadTimeout(毫秒) 等待响应数据的超时 只在非流式场景生效
RestClient.builder()...build() 建造者模式:链式配置后 build() 产出最终对象 requestFactory(factory) 把超时配置装进去
HttpClient.create() Netty 的客户端构造入口(WebClient 的底层 IO) 链式调用每个方法返回自身,可继续配置
.option(ChannelOption.CONNECT_TIMEOUT_MILLIS, 5000) Netty 的 TCP 连接超时(毫秒) 参数是"选项常量 + 数值"的形式
.responseTimeout(Duration.ofSeconds(120)) 从发出请求到收到最后一个字节的整体超时 Duration 类型,不用手动换算毫秒
.doOnConnected(conn -> ...) 连接建立成功后的回调,conn 是连接对象 在这里给连接挂"处理器"
addHandlerLast(new ReadTimeoutHandler(120, TimeUnit.SECONDS)) 读空闲超时:两个数据包之间最长间隔 120s SSE 每隔几秒就有一帧,超 120s 无数据才判超时;参数是"数值 + 单位"
WriteTimeoutHandler(60, TimeUnit.SECONDS) 写空闲超时 请求体较大时 60s 内写不出去才报错
WebClient.builder().clientConnector(...).build() 把配好的 Netty 客户端"插"进 WebClient ReactorClientHttpConnector 是衔接两者的适配器

4.4 WebClient 超时策略为何更长?

参数 RestClient WebClient 为什么 WebClient 更长
连接超时 5s 5s 相同
读取/响应超时 60s 120s 流式响应可能持续几分钟
读空闲超时 120s SSE 心跳间隔内不能断

4.5 两种客户端的使用模式对比

RestClient (非流式) Service RestClient (非流式) Service #mermaid-svg-4oqFuFxanXy9JmYw{font-family:"trebuchet ms",verdana,arial,sans-serif;font-size:16px;fill:#333;}@keyframes edge-animation-frame{from{stroke-dashoffset:0;}}@keyframes dash{to{stroke-dashoffset:0;}}#mermaid-svg-4oqFuFxanXy9JmYw .edge-animation-slow{stroke-dasharray:9,5!important;stroke-dashoffset:900;animation:dash 50s linear infinite;stroke-linecap:round;}#mermaid-svg-4oqFuFxanXy9JmYw .edge-animation-fast{stroke-dasharray:9,5!important;stroke-dashoffset:900;animation:dash 20s linear infinite;stroke-linecap:round;}#mermaid-svg-4oqFuFxanXy9JmYw .error-icon{fill:#552222;}#mermaid-svg-4oqFuFxanXy9JmYw .error-text{fill:#552222;stroke:#552222;}#mermaid-svg-4oqFuFxanXy9JmYw .edge-thickness-normal{stroke-width:1px;}#mermaid-svg-4oqFuFxanXy9JmYw .edge-thickness-thick{stroke-width:3.5px;}#mermaid-svg-4oqFuFxanXy9JmYw .edge-pattern-solid{stroke-dasharray:0;}#mermaid-svg-4oqFuFxanXy9JmYw .edge-thickness-invisible{stroke-width:0;fill:none;}#mermaid-svg-4oqFuFxanXy9JmYw .edge-pattern-dashed{stroke-dasharray:3;}#mermaid-svg-4oqFuFxanXy9JmYw .edge-pattern-dotted{stroke-dasharray:2;}#mermaid-svg-4oqFuFxanXy9JmYw .marker{fill:#333333;stroke:#333333;}#mermaid-svg-4oqFuFxanXy9JmYw .marker.cross{stroke:#333333;}#mermaid-svg-4oqFuFxanXy9JmYw svg{font-family:"trebuchet ms",verdana,arial,sans-serif;font-size:16px;}#mermaid-svg-4oqFuFxanXy9JmYw p{margin:0;}#mermaid-svg-4oqFuFxanXy9JmYw .actor{stroke:hsl(259.6261682243, 59.7765363128%, 87.9019607843%);fill:#ECECFF;}#mermaid-svg-4oqFuFxanXy9JmYw text.actor>tspan{fill:black;stroke:none;}#mermaid-svg-4oqFuFxanXy9JmYw .actor-line{stroke:hsl(259.6261682243, 59.7765363128%, 87.9019607843%);}#mermaid-svg-4oqFuFxanXy9JmYw .innerArc{stroke-width:1.5;stroke-dasharray:none;}#mermaid-svg-4oqFuFxanXy9JmYw .messageLine0{stroke-width:1.5;stroke-dasharray:none;stroke:#333;}#mermaid-svg-4oqFuFxanXy9JmYw .messageLine1{stroke-width:1.5;stroke-dasharray:2,2;stroke:#333;}#mermaid-svg-4oqFuFxanXy9JmYw #arrowhead path{fill:#333;stroke:#333;}#mermaid-svg-4oqFuFxanXy9JmYw .sequenceNumber{fill:white;}#mermaid-svg-4oqFuFxanXy9JmYw #sequencenumber{fill:#333;}#mermaid-svg-4oqFuFxanXy9JmYw #crosshead path{fill:#333;stroke:#333;}#mermaid-svg-4oqFuFxanXy9JmYw .messageText{fill:#333;stroke:none;}#mermaid-svg-4oqFuFxanXy9JmYw .labelBox{stroke:hsl(259.6261682243, 59.7765363128%, 87.9019607843%);fill:#ECECFF;}#mermaid-svg-4oqFuFxanXy9JmYw .labelText,#mermaid-svg-4oqFuFxanXy9JmYw .labelText>tspan{fill:black;stroke:none;}#mermaid-svg-4oqFuFxanXy9JmYw .loopText,#mermaid-svg-4oqFuFxanXy9JmYw .loopText>tspan{fill:black;stroke:none;}#mermaid-svg-4oqFuFxanXy9JmYw .loopLine{stroke-width:2px;stroke-dasharray:2,2;stroke:hsl(259.6261682243, 59.7765363128%, 87.9019607843%);fill:hsl(259.6261682243, 59.7765363128%, 87.9019607843%);}#mermaid-svg-4oqFuFxanXy9JmYw .note{stroke:#aaaa33;fill:#fff5ad;}#mermaid-svg-4oqFuFxanXy9JmYw .noteText,#mermaid-svg-4oqFuFxanXy9JmYw .noteText>tspan{fill:black;stroke:none;}#mermaid-svg-4oqFuFxanXy9JmYw .activation0{fill:#f4f4f4;stroke:#666;}#mermaid-svg-4oqFuFxanXy9JmYw .activation1{fill:#f4f4f4;stroke:#666;}#mermaid-svg-4oqFuFxanXy9JmYw .activation2{fill:#f4f4f4;stroke:#666;}#mermaid-svg-4oqFuFxanXy9JmYw .actorPopupMenu{position:absolute;}#mermaid-svg-4oqFuFxanXy9JmYw .actorPopupMenuPanel{position:absolute;fill:#ECECFF;box-shadow:0px 8px 16px 0px rgba(0,0,0,0.2);filter:drop-shadow(3px 5px 2px rgb(0 0 0 / 0.4));}#mermaid-svg-4oqFuFxanXy9JmYw .actor-man line{stroke:hsl(259.6261682243, 59.7765363128%, 87.9019607843%);fill:#ECECFF;}#mermaid-svg-4oqFuFxanXy9JmYw .actor-man circle,#mermaid-svg-4oqFuFxanXy9JmYw line{stroke:hsl(259.6261682243, 59.7765363128%, 87.9019607843%);fill:#ECECFF;stroke-width:2px;}#mermaid-svg-4oqFuFxanXy9JmYw :root{--mermaid-font-family:"trebuchet ms",verdana,arial,sans-serif;} 阻塞等待... post().uri().header().body().retrieve() 返回完整 Map<String,Object> 直接 return
WebClient (流式) Service WebClient (流式) Service #mermaid-svg-xTkjd4hmgfQFotfP{font-family:"trebuchet ms",verdana,arial,sans-serif;font-size:16px;fill:#333;}@keyframes edge-animation-frame{from{stroke-dashoffset:0;}}@keyframes dash{to{stroke-dashoffset:0;}}#mermaid-svg-xTkjd4hmgfQFotfP .edge-animation-slow{stroke-dasharray:9,5!important;stroke-dashoffset:900;animation:dash 50s linear infinite;stroke-linecap:round;}#mermaid-svg-xTkjd4hmgfQFotfP .edge-animation-fast{stroke-dasharray:9,5!important;stroke-dashoffset:900;animation:dash 20s linear infinite;stroke-linecap:round;}#mermaid-svg-xTkjd4hmgfQFotfP .error-icon{fill:#552222;}#mermaid-svg-xTkjd4hmgfQFotfP .error-text{fill:#552222;stroke:#552222;}#mermaid-svg-xTkjd4hmgfQFotfP .edge-thickness-normal{stroke-width:1px;}#mermaid-svg-xTkjd4hmgfQFotfP .edge-thickness-thick{stroke-width:3.5px;}#mermaid-svg-xTkjd4hmgfQFotfP .edge-pattern-solid{stroke-dasharray:0;}#mermaid-svg-xTkjd4hmgfQFotfP .edge-thickness-invisible{stroke-width:0;fill:none;}#mermaid-svg-xTkjd4hmgfQFotfP .edge-pattern-dashed{stroke-dasharray:3;}#mermaid-svg-xTkjd4hmgfQFotfP .edge-pattern-dotted{stroke-dasharray:2;}#mermaid-svg-xTkjd4hmgfQFotfP .marker{fill:#333333;stroke:#333333;}#mermaid-svg-xTkjd4hmgfQFotfP .marker.cross{stroke:#333333;}#mermaid-svg-xTkjd4hmgfQFotfP svg{font-family:"trebuchet ms",verdana,arial,sans-serif;font-size:16px;}#mermaid-svg-xTkjd4hmgfQFotfP p{margin:0;}#mermaid-svg-xTkjd4hmgfQFotfP .actor{stroke:hsl(259.6261682243, 59.7765363128%, 87.9019607843%);fill:#ECECFF;}#mermaid-svg-xTkjd4hmgfQFotfP text.actor>tspan{fill:black;stroke:none;}#mermaid-svg-xTkjd4hmgfQFotfP .actor-line{stroke:hsl(259.6261682243, 59.7765363128%, 87.9019607843%);}#mermaid-svg-xTkjd4hmgfQFotfP .innerArc{stroke-width:1.5;stroke-dasharray:none;}#mermaid-svg-xTkjd4hmgfQFotfP .messageLine0{stroke-width:1.5;stroke-dasharray:none;stroke:#333;}#mermaid-svg-xTkjd4hmgfQFotfP .messageLine1{stroke-width:1.5;stroke-dasharray:2,2;stroke:#333;}#mermaid-svg-xTkjd4hmgfQFotfP #arrowhead path{fill:#333;stroke:#333;}#mermaid-svg-xTkjd4hmgfQFotfP .sequenceNumber{fill:white;}#mermaid-svg-xTkjd4hmgfQFotfP #sequencenumber{fill:#333;}#mermaid-svg-xTkjd4hmgfQFotfP #crosshead path{fill:#333;stroke:#333;}#mermaid-svg-xTkjd4hmgfQFotfP .messageText{fill:#333;stroke:none;}#mermaid-svg-xTkjd4hmgfQFotfP .labelBox{stroke:hsl(259.6261682243, 59.7765363128%, 87.9019607843%);fill:#ECECFF;}#mermaid-svg-xTkjd4hmgfQFotfP .labelText,#mermaid-svg-xTkjd4hmgfQFotfP .labelText>tspan{fill:black;stroke:none;}#mermaid-svg-xTkjd4hmgfQFotfP .loopText,#mermaid-svg-xTkjd4hmgfQFotfP .loopText>tspan{fill:black;stroke:none;}#mermaid-svg-xTkjd4hmgfQFotfP .loopLine{stroke-width:2px;stroke-dasharray:2,2;stroke:hsl(259.6261682243, 59.7765363128%, 87.9019607843%);fill:hsl(259.6261682243, 59.7765363128%, 87.9019607843%);}#mermaid-svg-xTkjd4hmgfQFotfP .note{stroke:#aaaa33;fill:#fff5ad;}#mermaid-svg-xTkjd4hmgfQFotfP .noteText,#mermaid-svg-xTkjd4hmgfQFotfP .noteText>tspan{fill:black;stroke:none;}#mermaid-svg-xTkjd4hmgfQFotfP .activation0{fill:#f4f4f4;stroke:#666;}#mermaid-svg-xTkjd4hmgfQFotfP .activation1{fill:#f4f4f4;stroke:#666;}#mermaid-svg-xTkjd4hmgfQFotfP .activation2{fill:#f4f4f4;stroke:#666;}#mermaid-svg-xTkjd4hmgfQFotfP .actorPopupMenu{position:absolute;}#mermaid-svg-xTkjd4hmgfQFotfP .actorPopupMenuPanel{position:absolute;fill:#ECECFF;box-shadow:0px 8px 16px 0px rgba(0,0,0,0.2);filter:drop-shadow(3px 5px 2px rgb(0 0 0 / 0.4));}#mermaid-svg-xTkjd4hmgfQFotfP .actor-man line{stroke:hsl(259.6261682243, 59.7765363128%, 87.9019607843%);fill:#ECECFF;}#mermaid-svg-xTkjd4hmgfQFotfP .actor-man circle,#mermaid-svg-xTkjd4hmgfQFotfP line{stroke:hsl(259.6261682243, 59.7765363128%, 87.9019607843%);fill:#ECECFF;stroke-width:2px;}#mermaid-svg-xTkjd4hmgfQFotfP :root{--mermaid-font-family:"trebuchet ms",verdana,arial,sans-serif;} 流式数据在 Flux 中持续推送 post().uri().header().bodyValue().retrieve() 返回 Flux<ServerSentEvent<String>> 每收到一帧 SSE,原样中继给前端

衔接下一章:HTTP 客户端就绪后,我们需要理解"要发送什么格式的数据给上游"。这正是 LLM 协议的核心------下一章将深度解析三大协议的格式差异、适用场景和设计哲学。


第五章 三大 LLM 协议深度解析

本章定位:本教程的理论核心。你将理解 OpenAI Chat Completions、OpenAI Responses、Anthropic Messages 三种协议的设计差异、格式规范、适用场景,为第六章 Service 层的"原生透传"实现打下坚实基础。

5.1 协议全景对比

特性 OpenAI Chat Completions OpenAI Responses Anthropic Messages
诞生时间 2023 年初 2024 年末 2023 年中
系统提示词 messages 数组中 role: "system" 顶层 instructions 字段 顶层 system 字段
输入字段名 messages(数组) input(数组) messages(数组)
输入内容格式 纯字符串 content: "文本" 结构化 {type: "input_text", text: "文本"} 纯字符串 content: "文本"
max_tokens 参数名 max_tokens(可选) max_output_tokens(可选) max_tokens必填
认证方式 Authorization: Bearer <key> Authorization: Bearer <key> x-api-key: <key> + anthropic-version
非流式正文位置 choices[0].message.content output[]output_text 片段(兼容嵌套 message.content[] content[]type=texttext
流式增量事件 无 event 名,data: {choices:[...]} event: response.output_text.delta event: content_block_delta
流式结束标志 data: [DONE] event: response.completed event: message_stop
上游端点路径 /chat/completions /responses /messages
厂商兼容性 几乎所有厂商支持 仅 OpenAI 原生 仅 Anthropic 原生

5.2 OpenAI Chat Completions------最经典的对话协议

设计理念:简单即美。你给我一组消息(历史对话),我给你一条回复。

请求体格式
json 复制代码
{
  "model": "mimo-v2.5",
  "messages": [
    {"role": "system", "content": "你是吉林省文旅推荐官"},
    {"role": "user", "content": "介绍吉林省文旅资源"}
  ],
  "max_tokens": 8192,
  "stream": false
}

字段详解

  • messages 数组:消息历史,按时间顺序排列。role 可以是 system(系统提示词)、user(用户输入)、assistant(模型回复)。
  • system 消息:告诉模型"你是谁",放在 messages 数组的第一条。
  • max_tokens:可选参数,控制模型输出的最大 token 数。
非流式响应格式
json 复制代码
{
  "choices": [
    {
      "message": {
        "role": "assistant",
        "content": "吉林省拥有丰富的旅游资源..."
      }
    }
  ],
  "usage": {
    "prompt_tokens": 15,
    "completion_tokens": 256,
    "total_tokens": 271
  }
}
流式 SSE 事件格式
复制代码
data: {"choices":[{"delta":{"content":"吉林"}}]}

data: {"choices":[{"delta":{"content":"省"}}]}

data: {"choices":[{"delta":{"content":"拥有"}}]}

data: [DONE]

注意:没有 event: 行,只有 data: 行。以 data: [DONE] 标记结束。

5.3 OpenAI Responses------功能更丰富的下一代协议

设计理念:在 Chat Completions 基础上增加结构化输入/输出、推理链、工具调用等能力。

请求体格式
json 复制代码
{
  "model": "mimo-v2.5",
  "instructions": "你是吉林省文旅推荐官",
  "input": [
    {
      "role": "user",
      "content": [
        {"type": "input_text", "text": "介绍吉林省文旅资源"}
      ]
    }
  ],
  "max_output_tokens": 8192,
  "stream": false
}

字段详解

  • instructions:系统提示词提升为顶层字段,语义更明确(不再是消息列表中的一条)。
  • input:输入字段改名为 input(区别于 messages),且 content 必须用结构化格式。
  • max_output_tokens:参数名变了(注意不是 max_tokens)。
非流式响应格式
json 复制代码
{
  "output": [
    {
      "type": "output_text",
      "text": "吉林省拥有丰富的旅游资源..."
    }
  ],
  "usage": {
    "input_tokens": 15,
    "output_tokens": 256
  }
}
流式 SSE 事件格式
复制代码
event: response.created
data: {"type":"response.created","response":{...}}

event: response.output_text.delta
data: {"type":"response.output_text.delta","delta":"吉林"}

event: response.output_text.delta
data: {"type":"response.output_text.delta","delta":"省"}

event: response.completed
data: {"type":"response.completed","response":{...}}

注意:有明确的 event: 行标识事件类型,且结束标志是 response.completed 而非 [DONE]

5.4 Anthropic Messages------独立生态的对话协议

设计理念:Anthropic 从 Claude 模型出发独立设计,强调安全性和可控性。

请求体格式
json 复制代码
{
  "model": "mimo-v2.5",
  "max_tokens": 8192,
  "system": "你是吉林省文旅推荐官",
  "messages": [
    {"role": "user", "content": "介绍吉林省文旅资源"}
  ],
  "stream": false
}

字段详解

  • system:系统提示词用顶层 system 字段,与 OpenAI 的 instructions 类似但字段名不同。
  • max_tokens必填参数------这是 Anthropic 协议的硬性要求,遗漏会返回 400 错误。
  • messages:同名但格式与 Completions 类似(不包含 system 消息)。
认证方式差异
复制代码
OpenAI 系: Authorization: Bearer proxy_sk_xxx
Anthropic: x-api-key: proxy_sk_xxx
           anthropic-version: 2023-06-01
流式 SSE 事件格式
复制代码
event: message_start
data: {"type":"message_start","message":{...}}

event: content_block_start
data: {"type":"content_block_start","index":0,...}

event: content_block_delta
data: {"type":"content_block_delta","delta":{"type":"text_delta","text":"吉林"}}

event: content_block_delta
data: {"type":"content_block_delta","delta":{"type":"text_delta","text":"省"}}

event: message_stop
data: {"type":"message_stop"}

注意:事件名更丰富(message_startcontent_block_startcontent_block_deltamessage_stop),且增量文本在 delta.text 中(不是 delta.content)。

5.5 选型建议

使用场景 推荐协议 理由
通用对话(最大兼容性) Chat Completions 所有厂商都支持,生态最完善
需要结构化输入/输出 Responses 支持分块结构化 content
使用 Claude 模型 Messages Anthropic 原生协议
国内大模型 API 代理 Chat Completions 国内厂商大多兼容 OpenAI 格式

衔接下一章:协议格式已经了然于胸,接下来就是"用代码实现透传"。下一章将进入 Service 层,你将看到 RestClient 和 WebClient 如何在实际代码中完成非流式调用和流式 SSE 中继。


第六章 Service 层------协议透传的心脏

本章定位:整个项目的核心业务逻辑在这里。三个 Service 类分别负责三种协议的非流式和流式调用。读完本章,你将完整理解"代理透传"的实现细节,包括如何设置认证头、如何处理 SSE 事件流、如何兜底必填参数。

6.1 Service 层的设计角色

#mermaid-svg-VpNc063igYStBrSZ{font-family:"trebuchet ms",verdana,arial,sans-serif;font-size:16px;fill:#333;}@keyframes edge-animation-frame{from{stroke-dashoffset:0;}}@keyframes dash{to{stroke-dashoffset:0;}}#mermaid-svg-VpNc063igYStBrSZ .edge-animation-slow{stroke-dasharray:9,5!important;stroke-dashoffset:900;animation:dash 50s linear infinite;stroke-linecap:round;}#mermaid-svg-VpNc063igYStBrSZ .edge-animation-fast{stroke-dasharray:9,5!important;stroke-dashoffset:900;animation:dash 20s linear infinite;stroke-linecap:round;}#mermaid-svg-VpNc063igYStBrSZ .error-icon{fill:#552222;}#mermaid-svg-VpNc063igYStBrSZ .error-text{fill:#552222;stroke:#552222;}#mermaid-svg-VpNc063igYStBrSZ .edge-thickness-normal{stroke-width:1px;}#mermaid-svg-VpNc063igYStBrSZ .edge-thickness-thick{stroke-width:3.5px;}#mermaid-svg-VpNc063igYStBrSZ .edge-pattern-solid{stroke-dasharray:0;}#mermaid-svg-VpNc063igYStBrSZ .edge-thickness-invisible{stroke-width:0;fill:none;}#mermaid-svg-VpNc063igYStBrSZ .edge-pattern-dashed{stroke-dasharray:3;}#mermaid-svg-VpNc063igYStBrSZ .edge-pattern-dotted{stroke-dasharray:2;}#mermaid-svg-VpNc063igYStBrSZ .marker{fill:#333333;stroke:#333333;}#mermaid-svg-VpNc063igYStBrSZ .marker.cross{stroke:#333333;}#mermaid-svg-VpNc063igYStBrSZ svg{font-family:"trebuchet ms",verdana,arial,sans-serif;font-size:16px;}#mermaid-svg-VpNc063igYStBrSZ p{margin:0;}#mermaid-svg-VpNc063igYStBrSZ .label{font-family:"trebuchet ms",verdana,arial,sans-serif;color:#333;}#mermaid-svg-VpNc063igYStBrSZ .cluster-label text{fill:#333;}#mermaid-svg-VpNc063igYStBrSZ .cluster-label span{color:#333;}#mermaid-svg-VpNc063igYStBrSZ .cluster-label span p{background-color:transparent;}#mermaid-svg-VpNc063igYStBrSZ .label text,#mermaid-svg-VpNc063igYStBrSZ span{fill:#333;color:#333;}#mermaid-svg-VpNc063igYStBrSZ .node rect,#mermaid-svg-VpNc063igYStBrSZ .node circle,#mermaid-svg-VpNc063igYStBrSZ .node ellipse,#mermaid-svg-VpNc063igYStBrSZ .node polygon,#mermaid-svg-VpNc063igYStBrSZ .node path{fill:#ECECFF;stroke:#9370DB;stroke-width:1px;}#mermaid-svg-VpNc063igYStBrSZ .rough-node .label text,#mermaid-svg-VpNc063igYStBrSZ .node .label text,#mermaid-svg-VpNc063igYStBrSZ .image-shape .label,#mermaid-svg-VpNc063igYStBrSZ .icon-shape .label{text-anchor:middle;}#mermaid-svg-VpNc063igYStBrSZ .node .katex path{fill:#000;stroke:#000;stroke-width:1px;}#mermaid-svg-VpNc063igYStBrSZ .rough-node .label,#mermaid-svg-VpNc063igYStBrSZ .node .label,#mermaid-svg-VpNc063igYStBrSZ .image-shape .label,#mermaid-svg-VpNc063igYStBrSZ .icon-shape .label{text-align:center;}#mermaid-svg-VpNc063igYStBrSZ .node.clickable{cursor:pointer;}#mermaid-svg-VpNc063igYStBrSZ .root .anchor path{fill:#333333!important;stroke-width:0;stroke:#333333;}#mermaid-svg-VpNc063igYStBrSZ .arrowheadPath{fill:#333333;}#mermaid-svg-VpNc063igYStBrSZ .edgePath .path{stroke:#333333;stroke-width:2.0px;}#mermaid-svg-VpNc063igYStBrSZ .flowchart-link{stroke:#333333;fill:none;}#mermaid-svg-VpNc063igYStBrSZ .edgeLabel{background-color:rgba(232,232,232, 0.8);text-align:center;}#mermaid-svg-VpNc063igYStBrSZ .edgeLabel p{background-color:rgba(232,232,232, 0.8);}#mermaid-svg-VpNc063igYStBrSZ .edgeLabel rect{opacity:0.5;background-color:rgba(232,232,232, 0.8);fill:rgba(232,232,232, 0.8);}#mermaid-svg-VpNc063igYStBrSZ .labelBkg{background-color:rgba(232, 232, 232, 0.5);}#mermaid-svg-VpNc063igYStBrSZ .cluster rect{fill:#ffffde;stroke:#aaaa33;stroke-width:1px;}#mermaid-svg-VpNc063igYStBrSZ .cluster text{fill:#333;}#mermaid-svg-VpNc063igYStBrSZ .cluster span{color:#333;}#mermaid-svg-VpNc063igYStBrSZ div.mermaidTooltip{position:absolute;text-align:center;max-width:200px;padding:2px;font-family:"trebuchet ms",verdana,arial,sans-serif;font-size:12px;background:hsl(80, 100%, 96.2745098039%);border:1px solid #aaaa33;border-radius:2px;pointer-events:none;z-index:100;}#mermaid-svg-VpNc063igYStBrSZ .flowchartTitleText{text-anchor:middle;font-size:18px;fill:#333;}#mermaid-svg-VpNc063igYStBrSZ rect.text{fill:none;stroke-width:0;}#mermaid-svg-VpNc063igYStBrSZ .icon-shape,#mermaid-svg-VpNc063igYStBrSZ .image-shape{background-color:rgba(232,232,232, 0.8);text-align:center;}#mermaid-svg-VpNc063igYStBrSZ .icon-shape p,#mermaid-svg-VpNc063igYStBrSZ .image-shape p{background-color:rgba(232,232,232, 0.8);padding:2px;}#mermaid-svg-VpNc063igYStBrSZ .icon-shape .label rect,#mermaid-svg-VpNc063igYStBrSZ .image-shape .label rect{opacity:0.5;background-color:rgba(232,232,232, 0.8);fill:rgba(232,232,232, 0.8);}#mermaid-svg-VpNc063igYStBrSZ .label-icon{display:inline-block;height:1em;overflow:visible;vertical-align:-0.125em;}#mermaid-svg-VpNc063igYStBrSZ .node .label-icon path{fill:currentColor;stroke:revert;stroke-width:revert;}#mermaid-svg-VpNc063igYStBrSZ :root{--mermaid-font-family:"trebuchet ms",verdana,arial,sans-serif;} 接收 Controller

传来的 Map 请求体
putIfAbsent

兜底必填参数
拼装认证头

(根据协议类型)
调用

RestClient/WebClient
捕获异常

包装为 ApiException
原样返回

上游响应

核心原则:原生透传

Service 层不做任何协议转换。前端按 OpenAI 格式发请求,Service 就按 OpenAI 格式转发到上游;前端按 Anthropic 格式发请求,Service 就按 Anthropic 格式转发。响应也一样------上游返回什么,Service 就原样返回什么。

6.2 OpenAiCompletionsService 完整实现

java 复制代码
package com.lihaozhe.service;

import tools.jackson.databind.ObjectMapper;
import com.lihaozhe.config.UpstreamConfig;
import com.lihaozhe.exception.ApiException;
import lombok.extern.slf4j.Slf4j;
import org.springframework.core.ParameterizedTypeReference;
import org.springframework.http.HttpHeaders;
import org.springframework.http.HttpStatusCode;
import org.springframework.http.MediaType;
import org.springframework.http.codec.ServerSentEvent;
import org.springframework.stereotype.Service;
import org.springframework.web.client.HttpStatusCodeException;
import org.springframework.web.client.RestClient;
import org.springframework.web.reactive.function.client.WebClient;
import reactor.core.publisher.Flux;
import reactor.core.publisher.Mono;

import java.util.Map;

/**
 * OpenAI Chat Completions 协议转发(非流式 + 流式)
 *
 * <h2>协议要点</h2>
 * <ul>
 *   <li>系统提示词: messages 数组中 role=system 的消息</li>
 *   <li>认证头: {@code Authorization: Bearer <apiKey>}</li>
 *   <li>max_tokens: 可选参数,不传时由服务端按模型上限决定</li>
 *   <li>非流式正文: {@code choices[0].message.content}</li>
 *   <li>流式增量: {@code data: {choices:[{delta:{content}}]}},
 *       以 {@code data: [DONE]} 结束</li>
 * </ul>
 *
 * <h2>请求体示例(前端按此协议拼装,本类原样透传)</h2>
 * <pre>
 * {
 *   "model": "mimo-v2.5",
 *   "messages": [
 *     {"role": "system", "content": "你是吉林省文旅推荐官"},
 *     {"role": "user", "content": "介绍吉林省文旅资源"}
 *   ],
 *   "max_tokens": 8192,
 *   "stream": false
 * }
 * </pre>
 *
 * <p>响应体与请求体同协议,原样返回给客户端。
 * @author 李昊哲
 * @since 1.0.0
 */
@Slf4j
@Service
public class OpenAiCompletionsService {

    /** 上游端点路径(拼在 base-url 之后) */
    private static final String UPSTREAM_PATH = "/chat/completions";

    /** JSON 解析器:把上游返回的 JSON 字符串解析成 Map */
    private static final ObjectMapper MAPPER = new ObjectMapper();

    private final UpstreamConfig config;
    private final RestClient restClient;
    private final WebClient webClient;

    public OpenAiCompletionsService(
            UpstreamConfig config, RestClient restClient, WebClient webClient) {
        this.config = config;
        this.restClient = restClient;
        this.webClient = webClient;
    }

    /**
     * 非流式对话 ------ 请求体原样透传,响应体原样返回
     *
     * @param body 客户端发来的完整 Chat Completions 请求体
     * @return 上游完整响应体({@code {choices:[{message:{content}}], usage}})
     */
    @SuppressWarnings("unchecked")
    public Map<String, Object> chat(Map<String, Object> body) {
        try {
            // 上游可能返回 application/octet-stream 而非 application/json,
            // 导致 RestClient .body(Map.class) 的 Jackson 反序列化失败。
            // 先以 String 接收, 再手动解析为 Map, 兼容任意 Content-Type。
            String json = restClient.post()
                    .uri(config.getBaseUrl() + UPSTREAM_PATH)
                    .header("Authorization", "Bearer " + config.getApiKey())
                    .contentType(MediaType.APPLICATION_JSON)
                    .accept(MediaType.APPLICATION_JSON)
                    .body(body)
                    .retrieve()
                    .body(String.class);
            return MAPPER.readValue(json, Map.class);
        } catch (HttpStatusCodeException e) {
            int code = e.getStatusCode().value();
            Integer retryAfter = parseRetryAfter(e.getResponseHeaders());
            throw new ApiException(
                    "上游 " + e.getStatusCode() + ": " + e.getResponseBodyAsString(),
                    "openai-completions", code, retryAfter);
        } catch (Exception e) {
            throw new ApiException(
                    "Completions 响应解析失败: " + e.getMessage(),
                    "openai-completions", 502);
        }
    }

    /**
     * 流式对话 ------ 上游 SSE 帧原样中继(不解析 JSON、不提取文本)
     *
     * <p>WebClient 把上游 SSE 流解析为 {@link ServerSentEvent}(事件名 + 数据),
     * 本方法仅原样重发。事件格式:{@code data: {choices:[{delta:{content}}]}}
     * 持续推送增量,以 {@code data: [DONE]} 结束。
     *
     * @param body 客户端发来的完整请求体(需已带 stream:true)
     * @return SSE 事件流
     */
    public Flux<ServerSentEvent<String>> chatStream(Map<String, Object> body) {
        return webClient.post()
                .uri(config.getBaseUrl() + UPSTREAM_PATH)
                .header("Authorization", "Bearer " + config.getApiKey())
                .contentType(MediaType.APPLICATION_JSON)
                .accept(MediaType.TEXT_EVENT_STREAM)
                .bodyValue(body)
                .retrieve()
                .onStatus(HttpStatusCode::isError, resp -> resp.bodyToMono(String.class)
                        .defaultIfEmpty("")
                        .flatMap(msg -> {
                            int code = resp.statusCode().value();
                            Integer retryAfter = parseRetryAfter(
                                    resp.headers().asHttpHeaders());
                            return Mono.error(new ApiException(
                                    "上游 " + resp.statusCode() + ": " + msg,
                                    "openai-completions", code, retryAfter));
                        }))
                // 上游 SSE 帧 → ServerSentEvent(事件名 + 数据),原样中继
                .bodyToFlux(
                    new ParameterizedTypeReference<ServerSentEvent<String>>() {})
                .map(sse -> {
                    ServerSentEvent.Builder<String> b =
                            ServerSentEvent.builder(
                                    sse.data() == null ? "" : sse.data());
                    if (sse.event() != null) {
                        b.event(sse.event());
                    }
                    return b.build();
                })
                .onErrorResume(e -> {
                    log.warn("[openai-completions] 流式转发异常: {}", e.getMessage());
                    return Flux.just(
                            ServerSentEvent.<String>builder()
                                    .event("error")
                                    .data(String.valueOf(e.getMessage())).build(),
                            ServerSentEvent.<String>builder()
                                    .event("done").data("[DONE]").build());
                });
    }

    /** 从响应头中提取 Retry-After 秒数(仅 429/503 时上游会返回) */
    private static Integer parseRetryAfter(HttpHeaders headers) {
        if (headers == null) return null;
        String val = headers.getFirst("Retry-After");
        if (val == null || val.isBlank()) return null;
        try { return Integer.parseInt(val.trim()); }
        catch (NumberFormatException ignored) { return null; }
    }
}

初学者说明------方法与参数逐项拆解(6.3、6.4 的代码结构与本节完全相同,只讲一遍,后面只讲差异):

一、类定义与构造器

成员 含义 初学者要点
@Slf4j(Lombok) 自动生成 private static final Logger log 字段 代码里才能直接写 log.warn(...)
@Service 声明为业务层 Bean,交给 Spring 管理 Controller 通过构造器注入拿到它
UPSTREAM_PATH 常量 上游端点路径,如 /chat/completions 拼在 base-url 后面;换协议只改这一处
MAPPER 常量 Jackson 的 ObjectMapper,负责 JSON ↔ Map 互转 static final:全类共用一个,线程安全
private final ... 三字段 依赖对象 final + 构造器赋值 = Spring 构造器注入
构造器参数 (UpstreamConfig, RestClient, WebClient) 三个 Bean 从容器注入 顺序无所谓,Spring 按类型匹配

二、chat(Map<String, Object> body) ------ 非流式方法

调用 / 参数 作用 初学者要点
Map<String, Object> body(入参) 前端发来的完整请求体 不定义 DTO 类,用 Map 原样透传,增删字段都方便
Map<String, Object>(返回值) 上游的完整 JSON 响应 Spring MVC 自动把它序列化成 JSON 返给前端
@SuppressWarnings("unchecked") 抑制"未检查的强制转换"警告 MAPPER.readValue(json, Map.class) 返回的是原始 Map,强转泛型时编译器会告警
.post() 开始构建一个 POST 请求 对应还有 .get() / .put() / .delete()
.uri(String url) 目标地址 这里是 base-url + UPSTREAM_PATH 手动拼接
.header(name, value) 添加请求头,可多次调用 认证信息都靠它;Anthropic 协议要调两次(见 6.3)
.contentType(MediaType.APPLICATION_JSON) 请求头 Content-Type 告诉上游"我发的是 JSON"
.accept(MediaType.APPLICATION_JSON) 请求头 Accept 告诉上游"我想收 JSON"
.body(body) 请求体 RestClient 自动把 Map 序列化成 JSON 字节流(RestClient 专有;WebClient 里叫 bodyValue,见下)
.retrieve() 执行请求,返回"响应结果封装" 此刻 HTTP 请求已发出并阻塞等待响应
.body(String.class) 把响应体读成 String 不指定 MediaType 校验,任意 Content-Type 都能读
MAPPER.readValue(json, Map.class) 把 JSON 字符串解析成 Map 为什么要先 String 再解析?因为 .body(Map.class) 会在响应头不是 application/json 时直接抛异常(真实踩坑记录)
catch (HttpStatusCodeException e) 上游返回 4xx/5xx 时抛出的异常 getStatusCode().value() 取状态码数字;getResponseBodyAsString() 取上游错误原文;getResponseHeaders() 取响应头
catch (Exception e) 兜底:JSON 解析失败、网络中断等 包装成 502 的 ApiException,不让原始异常裸奔到前端
parseRetryAfter(HttpHeaders) 从响应头读 Retry-After 秒数 仅 429/503 限流时有值;getFirst(name) 没有该头返回 null;isBlank() 判空串

三、chatStream(Map<String, Object> body) ------ 流式方法(Reactor 入门)

先记两个核心类型:Flux<T> 是"将来会陆续产生 0...N 个 T 的流",Mono<T> 是"将来产生 0...1 个 T";它们像"还没执行的异步任务清单",直到被订阅(Controller 返回 Flux 时 Spring 会自动订阅)才真正开始跑。

调用 / 参数 作用 初学者要点
bodyValue(body) 设置请求体 WebClient 里发"现成的对象"用 bodyValue.body() 用于流式数据源,两者别混用
.accept(MediaType.TEXT_EVENT_STREAM) 请求头 Accept: text/event-stream 告诉上游"我要 SSE 流"
.onStatus(条件, 处理函数) 响应状态码满足条件时走异常分支 HttpStatusCode::isError 是方法引用,等价于 code -> code.isError()(4xx/5xx)
resp.bodyToMono(String.class) 把错误响应体异步读成 String Mono<String> 是"将来得到的那个字符串"
.defaultIfEmpty("") 错误体为空时给空串 防止后续逻辑拿到 null
.flatMap(msg -> Mono.error(...)) 拿到错误文本后,转成"以异常结束的流" flatMap:对每个元素做异步转换
.bodyToFlux(new ParameterizedTypeReference<ServerSentEvent<String>>() {}) 把 SSE 字节流解析成一个个 ServerSentEvent 匿名子类 new TypeReference<>(){} 是为了在运行期保留泛型类型(Java 泛型擦除,直接写 Flux.class 拿不到 ServerSentEvent<String>
ServerSentEvent<String> 一帧 SSE:event() 事件名 + data() 数据 上游每推一帧,流里就出现一个对象
.map(sse -> ...) 同步转换:把收到的帧重建成新帧 sse.data() == null ? "" : sse.data() 防空;b.event(...) 仅在有事件名时设置(Completions 的 data: 帧没有事件名)
.onErrorResume(e -> Flux.just(a, b)) 流中途出错时,改发兜底帧再正常结束 event:"error" 告知前端失败,再发 event:"done" 让前端停止等待
Flux<ServerSentEvent<String>>(返回值) Controller 拿到后直接返给前端 Spring MVC 支持响应式返回值,逐帧写回 HTTP 连接

6.3 AnthropicMessagesService 完整实现

与 OpenAI 系 Service 的关键差异已用注释标注:

java 复制代码
package com.lihaozhe.service;

import com.lihaozhe.config.UpstreamConfig;
import com.lihaozhe.exception.ApiException;
import lombok.extern.slf4j.Slf4j;
import org.springframework.core.ParameterizedTypeReference;
import org.springframework.http.HttpHeaders;
import org.springframework.http.HttpStatusCode;
import org.springframework.http.MediaType;
import org.springframework.http.codec.ServerSentEvent;
import org.springframework.stereotype.Service;
import org.springframework.web.client.HttpStatusCodeException;
import org.springframework.web.client.RestClient;
import org.springframework.web.reactive.function.client.WebClient;
import reactor.core.publisher.Flux;
import reactor.core.publisher.Mono;

import java.util.Map;

/**
 * Anthropic Messages 协议转发(非流式 + 流式)
 *
 * <h2>协议要点(与 OpenAI 系的差异标注)</h2>
 * <ul>
 *   <li>系统提示词: 顶层 system 字段</li>
 *   <li>认证头: x-api-key + anthropic-version(两件套)</li>
 *   <li>max_tokens: <b>必填参数</b>(协议规范要求,遗漏时本类兜底)</li>
 *   <li>非流式正文: content 数组中 type=text 块的 text 字段</li>
 *   <li>流式事件: message_start → content_block_delta → message_stop</li>
 * </ul>
 *
 * <h2>请求体示例(前端按此协议拼装,本类原样透传)</h2>
 * <pre>
 * {
 *   "model": "mimo-v2.5",
 *   "max_tokens": 8192,
 *   "system": "你是吉林省文旅推荐官",
 *   "messages": [{"role": "user", "content": "介绍吉林省文旅资源"}],
 *   "stream": false
 * }
 * </pre>
 *
 * <p>响应体与请求体同协议,原样返回给客户端。
 *
 * @author 李昊哲
 * @since 1.0.0
 */
@Slf4j
@Service
public class AnthropicMessagesService {

    private static final String UPSTREAM_PATH = "/messages";

    /** JSON 解析器:把上游返回的 JSON 字符串解析成 Map */
    private static final ObjectMapper MAPPER = new ObjectMapper();

    private final UpstreamConfig config;
    private final RestClient restClient;
    private final WebClient webClient;

    public AnthropicMessagesService(
            UpstreamConfig config, RestClient restClient, WebClient webClient) {
        this.config = config;
        this.restClient = restClient;
        this.webClient = webClient;
    }

    /**
     * 非流式对话
     *
     * <p><b>唯一的加工</b>: max_tokens 是协议必填参数,
     * 客户端遗漏时用配置默认值兜底,避免上游返回 400。
     */
    @SuppressWarnings("unchecked")
    public Map<String, Object> chat(Map<String, Object> body) {
        body.putIfAbsent("max_tokens", config.getMaxTokens());
        try {
            // 上游可能返回 application/octet-stream 而非 application/json,
            // 导致 RestClient .body(Map.class) 的 Jackson 反序列化失败。
            // 先以 String 接收, 再手动解析为 Map, 兼容任意 Content-Type。
            String json = restClient.post()
                    .uri(config.getBaseUrl() + UPSTREAM_PATH)
                    .header("x-api-key", config.getApiKey())
                    .header("anthropic-version", "2023-06-01")
                    .contentType(MediaType.APPLICATION_JSON)
                    .accept(MediaType.APPLICATION_JSON)
                    .body(body)
                    .retrieve()
                    .body(String.class);
            return MAPPER.readValue(json, Map.class);
        } catch (HttpStatusCodeException e) {
            int code = e.getStatusCode().value();
            Integer retryAfter = parseRetryAfter(e.getResponseHeaders());
            throw new ApiException(
                    "上游 " + e.getStatusCode() + ": " + e.getResponseBodyAsString(),
                    "anthropic-messages", code, retryAfter);
        } catch (Exception e) {
            throw new ApiException(
                    "Messages 响应解析失败: " + e.getMessage(),
                    "anthropic-messages", 502);
        }
    }

    /**
     * 流式对话
     *
     * <p>事件序列: message_start → content_block_start →
     * content_block_delta(增量文本在 delta.text) → message_stop
     */
    public Flux<ServerSentEvent<String>> chatStream(Map<String, Object> body) {
        body.putIfAbsent("max_tokens", config.getMaxTokens());
        return webClient.post()
                .uri(config.getBaseUrl() + UPSTREAM_PATH)
                .header("x-api-key", config.getApiKey())
                .header("anthropic-version", "2023-06-01")
                .contentType(MediaType.APPLICATION_JSON)
                .accept(MediaType.TEXT_EVENT_STREAM)
                .bodyValue(body)
                .retrieve()
                .onStatus(HttpStatusCode::isError, resp -> resp.bodyToMono(String.class)
                        .defaultIfEmpty("")
                        .flatMap(msg -> {
                            int code = resp.statusCode().value();
                            Integer retryAfter = parseRetryAfter(
                                    resp.headers().asHttpHeaders());
                            return Mono.error(new ApiException(
                                    "上游 " + resp.statusCode() + ": " + msg,
                                    "anthropic-messages", code, retryAfter));
                        }))
                // 上游 SSE 帧 → ServerSentEvent(事件名 + 数据),原样中继
                .bodyToFlux(
                    new ParameterizedTypeReference<ServerSentEvent<String>>() {})
                .map(sse -> {
                    ServerSentEvent.Builder<String> b =
                            ServerSentEvent.builder(
                                    sse.data() == null ? "" : sse.data());
                    if (sse.event() != null) {
                        b.event(sse.event());
                    }
                    return b.build();
                })
                .onErrorResume(e -> {
                    log.warn("[anthropic-messages] 流式转发异常: {}", e.getMessage());
                    return Flux.just(
                            ServerSentEvent.<String>builder()
                                    .event("error")
                                    .data(String.valueOf(e.getMessage())).build(),
                            ServerSentEvent.<String>builder()
                                    .event("done").data("[DONE]").build());
                });
    }

    private static Integer parseRetryAfter(HttpHeaders headers) {
        if (headers == null) return null;
        String val = headers.getFirst("Retry-After");
        if (val == null || val.isBlank()) return null;
        try { return Integer.parseInt(val.trim()); }
        catch (NumberFormatException ignored) { return null; }
    }
}

初学者说明------本类与 6.2 的差异点(其余方法链与参数逐项含义完全同 6.2,不再重复):

差异点 代码 说明
双认证头 .header("x-api-key", ...) + .header("anthropic-version", "2023-06-01") Anthropic 协议不用 Authorization: Bearer,而是"密钥头 + 版本头"两件套,少一个都会 401
必填参数兜底 body.putIfAbsent("max_tokens", config.getMaxTokens()) putIfAbsent(key, value)仅当 key 不存在时才放入默认值,客户端已传的值不会被覆盖;非流式和流式方法各调一次
兜底错误文案 "Messages 响应解析失败: ..." 502 兜底分支,仅当 JSON 解析或网络读取出错时触发

6.4 OpenAiResponsesService 完整实现

java 复制代码
package com.lihaozhe.service;

import com.lihaozhe.config.UpstreamConfig;
import com.lihaozhe.exception.ApiException;
import lombok.extern.slf4j.Slf4j;
import org.springframework.core.ParameterizedTypeReference;
import org.springframework.http.HttpHeaders;
import org.springframework.http.HttpStatusCode;
import org.springframework.http.MediaType;
import org.springframework.http.codec.ServerSentEvent;
import org.springframework.stereotype.Service;
import org.springframework.web.client.HttpStatusCodeException;
import org.springframework.web.client.RestClient;
import org.springframework.web.reactive.function.client.WebClient;
import reactor.core.publisher.Flux;
import reactor.core.publisher.Mono;

import java.util.Map;

/**
 * OpenAI Responses 协议转发(非流式 + 流式)
 *
 * <h2>协议要点(与 Chat Completions 的差异标注)</h2>
 * <ul>
 *   <li>系统提示词: 顶层 instructions 字段</li>
 *   <li>输入: input 字段,content 需用结构化格式</li>
 *   <li>输出上限: max_output_tokens(可选,注意与 max_tokens 不同名)</li>
 *   <li>非流式正文: output 数组中 type=output_text 片段的 text 字段</li>
 *   <li>流式增量: event: response.output_text.delta,文本在 data.delta</li>
 * </ul>
 *
 * <h2>请求体示例(前端按此协议拼装,本类原样透传)</h2>
 * <pre>
 * {
 *   "model": "mimo-v2.5",
 *   "instructions": "你是吉林省文旅推荐官",
 *   "input": [
 *     {"role": "user", "content": [{"type": "input_text", "text": "介绍吉林省文旅资源"}]}
 *   ],
 *   "max_output_tokens": 8192,
 *   "stream": false
 * }
 * </pre>
 *
 * <p>响应体与请求体同协议,原样返回给客户端。
 *
 * @author 李昊哲
 * @since 1.0.0
 */
@Slf4j
@Service
public class OpenAiResponsesService {

    private static final String UPSTREAM_PATH = "/responses";

    /** JSON 解析器:把上游返回的 JSON 字符串解析成 Map */
    private static final ObjectMapper MAPPER = new ObjectMapper();

    private final UpstreamConfig config;
    private final RestClient restClient;
    private final WebClient webClient;

    public OpenAiResponsesService(
            UpstreamConfig config, RestClient restClient, WebClient webClient) {
        this.config = config;
        this.restClient = restClient;
        this.webClient = webClient;
    }

    @SuppressWarnings("unchecked")
    public Map<String, Object> chat(Map<String, Object> body) {
        try {
            // 上游可能返回 application/octet-stream 而非 application/json,
            // 导致 RestClient .body(Map.class) 的 Jackson 反序列化失败。
            // 先以 String 接收, 再手动解析为 Map, 兼容任意 Content-Type。
            String json = restClient.post()
                    .uri(config.getBaseUrl() + UPSTREAM_PATH)
                    .header("Authorization", "Bearer " + config.getApiKey())
                    .contentType(MediaType.APPLICATION_JSON)
                    .accept(MediaType.APPLICATION_JSON)
                    .body(body)
                    .retrieve()
                    .body(String.class);
            return MAPPER.readValue(json, Map.class);
        } catch (HttpStatusCodeException e) {
            int code = e.getStatusCode().value();
            Integer retryAfter = parseRetryAfter(e.getResponseHeaders());
            throw new ApiException(
                    "上游 " + e.getStatusCode() + ": " + e.getResponseBodyAsString(),
                    "openai-responses", code, retryAfter);
        } catch (Exception e) {
            throw new ApiException(
                    "Responses 响应解析失败: " + e.getMessage(),
                    "openai-responses", 502);
        }
    }

    /**
     * 流式对话
     *
     * <p>事件序列: response.created → response.output_text.delta(增量文本) → response.completed
     */
    public Flux<ServerSentEvent<String>> chatStream(Map<String, Object> body) {
        return webClient.post()
                .uri(config.getBaseUrl() + UPSTREAM_PATH)
                .header("Authorization", "Bearer " + config.getApiKey())
                .contentType(MediaType.APPLICATION_JSON)
                .accept(MediaType.TEXT_EVENT_STREAM)
                .bodyValue(body)
                .retrieve()
                .onStatus(HttpStatusCode::isError, resp -> resp.bodyToMono(String.class)
                        .defaultIfEmpty("")
                        .flatMap(msg -> {
                            int code = resp.statusCode().value();
                            Integer retryAfter = parseRetryAfter(
                                    resp.headers().asHttpHeaders());
                            return Mono.error(new ApiException(
                                    "上游 " + resp.statusCode() + ": " + msg,
                                    "openai-responses", code, retryAfter));
                        }))
                // 上游 SSE 帧 → ServerSentEvent(事件名 + 数据),原样中继
                .bodyToFlux(
                    new ParameterizedTypeReference<ServerSentEvent<String>>() {})
                .map(sse -> {
                    ServerSentEvent.Builder<String> b =
                            ServerSentEvent.builder(
                                    sse.data() == null ? "" : sse.data());
                    if (sse.event() != null) {
                        b.event(sse.event());
                    }
                    return b.build();
                })
                .onErrorResume(e -> {
                    log.warn("[openai-responses] 流式转发异常: {}", e.getMessage());
                    return Flux.just(
                            ServerSentEvent.<String>builder()
                                    .event("error")
                                    .data(String.valueOf(e.getMessage())).build(),
                            ServerSentEvent.<String>builder()
                                    .event("done").data("[DONE]").build());
                });
    }

    private static Integer parseRetryAfter(HttpHeaders headers) {
        if (headers == null) return null;
        String val = headers.getFirst("Retry-After");
        if (val == null || val.isBlank()) return null;
        try { return Integer.parseInt(val.trim()); }
        catch (NumberFormatException ignored) { return null; }
    }
}

初学者说明------本类与 6.2 的差异点

差异点 代码 说明
上游路径 UPSTREAM_PATH = "/responses" 每个协议一个 Service 的原因之一:路径、认证、参数名各不相同
无参数兜底 非流式/流式都没有 putIfAbsent Responses 协议的 max_output_tokens 是可选参数,客户端不传也能调用
错误文案 "Responses 响应解析失败: ..." 与 6.2/6.3 相同的 502 兜底分支,仅文案不同

6.5 非流式调用时序图

上游 LLM RestClient Service Controller 前端 上游 LLM RestClient Service Controller 前端 #mermaid-svg-fHIu2ebgrOv58tqH{font-family:"trebuchet ms",verdana,arial,sans-serif;font-size:16px;fill:#333;}@keyframes edge-animation-frame{from{stroke-dashoffset:0;}}@keyframes dash{to{stroke-dashoffset:0;}}#mermaid-svg-fHIu2ebgrOv58tqH .edge-animation-slow{stroke-dasharray:9,5!important;stroke-dashoffset:900;animation:dash 50s linear infinite;stroke-linecap:round;}#mermaid-svg-fHIu2ebgrOv58tqH .edge-animation-fast{stroke-dasharray:9,5!important;stroke-dashoffset:900;animation:dash 20s linear infinite;stroke-linecap:round;}#mermaid-svg-fHIu2ebgrOv58tqH .error-icon{fill:#552222;}#mermaid-svg-fHIu2ebgrOv58tqH .error-text{fill:#552222;stroke:#552222;}#mermaid-svg-fHIu2ebgrOv58tqH .edge-thickness-normal{stroke-width:1px;}#mermaid-svg-fHIu2ebgrOv58tqH .edge-thickness-thick{stroke-width:3.5px;}#mermaid-svg-fHIu2ebgrOv58tqH .edge-pattern-solid{stroke-dasharray:0;}#mermaid-svg-fHIu2ebgrOv58tqH .edge-thickness-invisible{stroke-width:0;fill:none;}#mermaid-svg-fHIu2ebgrOv58tqH .edge-pattern-dashed{stroke-dasharray:3;}#mermaid-svg-fHIu2ebgrOv58tqH .edge-pattern-dotted{stroke-dasharray:2;}#mermaid-svg-fHIu2ebgrOv58tqH .marker{fill:#333333;stroke:#333333;}#mermaid-svg-fHIu2ebgrOv58tqH .marker.cross{stroke:#333333;}#mermaid-svg-fHIu2ebgrOv58tqH svg{font-family:"trebuchet ms",verdana,arial,sans-serif;font-size:16px;}#mermaid-svg-fHIu2ebgrOv58tqH p{margin:0;}#mermaid-svg-fHIu2ebgrOv58tqH .actor{stroke:hsl(259.6261682243, 59.7765363128%, 87.9019607843%);fill:#ECECFF;}#mermaid-svg-fHIu2ebgrOv58tqH text.actor>tspan{fill:black;stroke:none;}#mermaid-svg-fHIu2ebgrOv58tqH .actor-line{stroke:hsl(259.6261682243, 59.7765363128%, 87.9019607843%);}#mermaid-svg-fHIu2ebgrOv58tqH .innerArc{stroke-width:1.5;stroke-dasharray:none;}#mermaid-svg-fHIu2ebgrOv58tqH .messageLine0{stroke-width:1.5;stroke-dasharray:none;stroke:#333;}#mermaid-svg-fHIu2ebgrOv58tqH .messageLine1{stroke-width:1.5;stroke-dasharray:2,2;stroke:#333;}#mermaid-svg-fHIu2ebgrOv58tqH #arrowhead path{fill:#333;stroke:#333;}#mermaid-svg-fHIu2ebgrOv58tqH .sequenceNumber{fill:white;}#mermaid-svg-fHIu2ebgrOv58tqH #sequencenumber{fill:#333;}#mermaid-svg-fHIu2ebgrOv58tqH #crosshead path{fill:#333;stroke:#333;}#mermaid-svg-fHIu2ebgrOv58tqH .messageText{fill:#333;stroke:none;}#mermaid-svg-fHIu2ebgrOv58tqH .labelBox{stroke:hsl(259.6261682243, 59.7765363128%, 87.9019607843%);fill:#ECECFF;}#mermaid-svg-fHIu2ebgrOv58tqH .labelText,#mermaid-svg-fHIu2ebgrOv58tqH .labelText>tspan{fill:black;stroke:none;}#mermaid-svg-fHIu2ebgrOv58tqH .loopText,#mermaid-svg-fHIu2ebgrOv58tqH .loopText>tspan{fill:black;stroke:none;}#mermaid-svg-fHIu2ebgrOv58tqH .loopLine{stroke-width:2px;stroke-dasharray:2,2;stroke:hsl(259.6261682243, 59.7765363128%, 87.9019607843%);fill:hsl(259.6261682243, 59.7765363128%, 87.9019607843%);}#mermaid-svg-fHIu2ebgrOv58tqH .note{stroke:#aaaa33;fill:#fff5ad;}#mermaid-svg-fHIu2ebgrOv58tqH .noteText,#mermaid-svg-fHIu2ebgrOv58tqH .noteText>tspan{fill:black;stroke:none;}#mermaid-svg-fHIu2ebgrOv58tqH .activation0{fill:#f4f4f4;stroke:#666;}#mermaid-svg-fHIu2ebgrOv58tqH .activation1{fill:#f4f4f4;stroke:#666;}#mermaid-svg-fHIu2ebgrOv58tqH .activation2{fill:#f4f4f4;stroke:#666;}#mermaid-svg-fHIu2ebgrOv58tqH .actorPopupMenu{position:absolute;}#mermaid-svg-fHIu2ebgrOv58tqH .actorPopupMenuPanel{position:absolute;fill:#ECECFF;box-shadow:0px 8px 16px 0px rgba(0,0,0,0.2);filter:drop-shadow(3px 5px 2px rgb(0 0 0 / 0.4));}#mermaid-svg-fHIu2ebgrOv58tqH .actor-man line{stroke:hsl(259.6261682243, 59.7765363128%, 87.9019607843%);fill:#ECECFF;}#mermaid-svg-fHIu2ebgrOv58tqH .actor-man circle,#mermaid-svg-fHIu2ebgrOv58tqH line{stroke:hsl(259.6261682243, 59.7765363128%, 87.9019607843%);fill:#ECECFF;stroke-width:2px;}#mermaid-svg-fHIu2ebgrOv58tqH :root{--mermaid-font-family:"trebuchet ms",verdana,arial,sans-serif;} putIfAbsent 兜底参数 异常包装为 ApiException POST /api/openai-completions/chat chat(body) restClient.post().uri().header().body() HTTP POST (完整 JSON) HTTP 200 (完整 JSON 响应) Map<String,Object> return Map HTTP 200 (JSON)

6.6 流式 SSE 调用时序图

上游 LLM WebClient Service Controller 前端 上游 LLM WebClient Service Controller 前端 #mermaid-svg-tnm1dHfPFuJBsalO{font-family:"trebuchet ms",verdana,arial,sans-serif;font-size:16px;fill:#333;}@keyframes edge-animation-frame{from{stroke-dashoffset:0;}}@keyframes dash{to{stroke-dashoffset:0;}}#mermaid-svg-tnm1dHfPFuJBsalO .edge-animation-slow{stroke-dasharray:9,5!important;stroke-dashoffset:900;animation:dash 50s linear infinite;stroke-linecap:round;}#mermaid-svg-tnm1dHfPFuJBsalO .edge-animation-fast{stroke-dasharray:9,5!important;stroke-dashoffset:900;animation:dash 20s linear infinite;stroke-linecap:round;}#mermaid-svg-tnm1dHfPFuJBsalO .error-icon{fill:#552222;}#mermaid-svg-tnm1dHfPFuJBsalO .error-text{fill:#552222;stroke:#552222;}#mermaid-svg-tnm1dHfPFuJBsalO .edge-thickness-normal{stroke-width:1px;}#mermaid-svg-tnm1dHfPFuJBsalO .edge-thickness-thick{stroke-width:3.5px;}#mermaid-svg-tnm1dHfPFuJBsalO .edge-pattern-solid{stroke-dasharray:0;}#mermaid-svg-tnm1dHfPFuJBsalO .edge-thickness-invisible{stroke-width:0;fill:none;}#mermaid-svg-tnm1dHfPFuJBsalO .edge-pattern-dashed{stroke-dasharray:3;}#mermaid-svg-tnm1dHfPFuJBsalO .edge-pattern-dotted{stroke-dasharray:2;}#mermaid-svg-tnm1dHfPFuJBsalO .marker{fill:#333333;stroke:#333333;}#mermaid-svg-tnm1dHfPFuJBsalO .marker.cross{stroke:#333333;}#mermaid-svg-tnm1dHfPFuJBsalO svg{font-family:"trebuchet ms",verdana,arial,sans-serif;font-size:16px;}#mermaid-svg-tnm1dHfPFuJBsalO p{margin:0;}#mermaid-svg-tnm1dHfPFuJBsalO .actor{stroke:hsl(259.6261682243, 59.7765363128%, 87.9019607843%);fill:#ECECFF;}#mermaid-svg-tnm1dHfPFuJBsalO text.actor>tspan{fill:black;stroke:none;}#mermaid-svg-tnm1dHfPFuJBsalO .actor-line{stroke:hsl(259.6261682243, 59.7765363128%, 87.9019607843%);}#mermaid-svg-tnm1dHfPFuJBsalO .innerArc{stroke-width:1.5;stroke-dasharray:none;}#mermaid-svg-tnm1dHfPFuJBsalO .messageLine0{stroke-width:1.5;stroke-dasharray:none;stroke:#333;}#mermaid-svg-tnm1dHfPFuJBsalO .messageLine1{stroke-width:1.5;stroke-dasharray:2,2;stroke:#333;}#mermaid-svg-tnm1dHfPFuJBsalO #arrowhead path{fill:#333;stroke:#333;}#mermaid-svg-tnm1dHfPFuJBsalO .sequenceNumber{fill:white;}#mermaid-svg-tnm1dHfPFuJBsalO #sequencenumber{fill:#333;}#mermaid-svg-tnm1dHfPFuJBsalO #crosshead path{fill:#333;stroke:#333;}#mermaid-svg-tnm1dHfPFuJBsalO .messageText{fill:#333;stroke:none;}#mermaid-svg-tnm1dHfPFuJBsalO .labelBox{stroke:hsl(259.6261682243, 59.7765363128%, 87.9019607843%);fill:#ECECFF;}#mermaid-svg-tnm1dHfPFuJBsalO .labelText,#mermaid-svg-tnm1dHfPFuJBsalO .labelText>tspan{fill:black;stroke:none;}#mermaid-svg-tnm1dHfPFuJBsalO .loopText,#mermaid-svg-tnm1dHfPFuJBsalO .loopText>tspan{fill:black;stroke:none;}#mermaid-svg-tnm1dHfPFuJBsalO .loopLine{stroke-width:2px;stroke-dasharray:2,2;stroke:hsl(259.6261682243, 59.7765363128%, 87.9019607843%);fill:hsl(259.6261682243, 59.7765363128%, 87.9019607843%);}#mermaid-svg-tnm1dHfPFuJBsalO .note{stroke:#aaaa33;fill:#fff5ad;}#mermaid-svg-tnm1dHfPFuJBsalO .noteText,#mermaid-svg-tnm1dHfPFuJBsalO .noteText>tspan{fill:black;stroke:none;}#mermaid-svg-tnm1dHfPFuJBsalO .activation0{fill:#f4f4f4;stroke:#666;}#mermaid-svg-tnm1dHfPFuJBsalO .activation1{fill:#f4f4f4;stroke:#666;}#mermaid-svg-tnm1dHfPFuJBsalO .activation2{fill:#f4f4f4;stroke:#666;}#mermaid-svg-tnm1dHfPFuJBsalO .actorPopupMenu{position:absolute;}#mermaid-svg-tnm1dHfPFuJBsalO .actorPopupMenuPanel{position:absolute;fill:#ECECFF;box-shadow:0px 8px 16px 0px rgba(0,0,0,0.2);filter:drop-shadow(3px 5px 2px rgb(0 0 0 / 0.4));}#mermaid-svg-tnm1dHfPFuJBsalO .actor-man line{stroke:hsl(259.6261682243, 59.7765363128%, 87.9019607843%);fill:#ECECFF;}#mermaid-svg-tnm1dHfPFuJBsalO .actor-man circle,#mermaid-svg-tnm1dHfPFuJBsalO line{stroke:hsl(259.6261682243, 59.7765363128%, 87.9019607843%);fill:#ECECFF;stroke-width:2px;}#mermaid-svg-tnm1dHfPFuJBsalO :root{--mermaid-font-family:"trebuchet ms",verdana,arial,sans-serif;} 原样构建 ServerSentEvent POST /api/.../chat/stream chatStream(body) webClient.post().bodyValue(body) HTTP POST (stream:true) SSE: data: {delta:"吉"} Flux 提供第一帧 Flux 中发射第一帧 SSE event 第一帧 SSE: data: {delta:"林"} Flux 提供第二帧 Flux 中发射第二帧 SSE event 第二帧 SSE: data: DONE Flux 完成 流结束

6.7 三种 Service 的代码差异速查

差异点 Completions Service Responses Service Anthropic Service
上游路径 /chat/completions /responses /messages
认证头 Authorization: Bearer <key> Authorization: Bearer <key> x-api-key: <key> + anthropic-version
max_tokens 兜底 不需要 不需要 body.putIfAbsent("max_tokens", ...)
非流式响应接收 .body(String.class)MAPPER.readValue 解析为 Map 同左 同左

为什么要 String 中转? 三个 Service 的非流式响应都先以 String 接收再手动解析。若直接 .body(Map.class),RestClient 会校验响应头 Content-Type 必须是 application/json,上游一旦返回 application/octet-stream(真实发生过)就直接抛异常。String 接收不挑 Content-Type,是"透传型代理"的稳妥写法。
衔接下一章:Service 层负责"做什么"(透传请求),但还需要有人来"接收外部请求"并调用 Service------这就是 Controller 层的职责。下一章将讲解 HTTP 端点的暴露方式。


第七章 Controller 层------HTTP 端点暴露

本章定位:Controller 是前后端之间的"门卫"------接收前端的 HTTP 请求,调用 Service 处理,返回响应。本章代码非常简洁,因为所有复杂逻辑都已经下沉到 Service 层了。

7.1 Controller 的设计角色

#mermaid-svg-G3LZ0RdYzvBBm3Fr{font-family:"trebuchet ms",verdana,arial,sans-serif;font-size:16px;fill:#333;}@keyframes edge-animation-frame{from{stroke-dashoffset:0;}}@keyframes dash{to{stroke-dashoffset:0;}}#mermaid-svg-G3LZ0RdYzvBBm3Fr .edge-animation-slow{stroke-dasharray:9,5!important;stroke-dashoffset:900;animation:dash 50s linear infinite;stroke-linecap:round;}#mermaid-svg-G3LZ0RdYzvBBm3Fr .edge-animation-fast{stroke-dasharray:9,5!important;stroke-dashoffset:900;animation:dash 20s linear infinite;stroke-linecap:round;}#mermaid-svg-G3LZ0RdYzvBBm3Fr .error-icon{fill:#552222;}#mermaid-svg-G3LZ0RdYzvBBm3Fr .error-text{fill:#552222;stroke:#552222;}#mermaid-svg-G3LZ0RdYzvBBm3Fr .edge-thickness-normal{stroke-width:1px;}#mermaid-svg-G3LZ0RdYzvBBm3Fr .edge-thickness-thick{stroke-width:3.5px;}#mermaid-svg-G3LZ0RdYzvBBm3Fr .edge-pattern-solid{stroke-dasharray:0;}#mermaid-svg-G3LZ0RdYzvBBm3Fr .edge-thickness-invisible{stroke-width:0;fill:none;}#mermaid-svg-G3LZ0RdYzvBBm3Fr .edge-pattern-dashed{stroke-dasharray:3;}#mermaid-svg-G3LZ0RdYzvBBm3Fr .edge-pattern-dotted{stroke-dasharray:2;}#mermaid-svg-G3LZ0RdYzvBBm3Fr .marker{fill:#333333;stroke:#333333;}#mermaid-svg-G3LZ0RdYzvBBm3Fr .marker.cross{stroke:#333333;}#mermaid-svg-G3LZ0RdYzvBBm3Fr svg{font-family:"trebuchet ms",verdana,arial,sans-serif;font-size:16px;}#mermaid-svg-G3LZ0RdYzvBBm3Fr p{margin:0;}#mermaid-svg-G3LZ0RdYzvBBm3Fr .label{font-family:"trebuchet ms",verdana,arial,sans-serif;color:#333;}#mermaid-svg-G3LZ0RdYzvBBm3Fr .cluster-label text{fill:#333;}#mermaid-svg-G3LZ0RdYzvBBm3Fr .cluster-label span{color:#333;}#mermaid-svg-G3LZ0RdYzvBBm3Fr .cluster-label span p{background-color:transparent;}#mermaid-svg-G3LZ0RdYzvBBm3Fr .label text,#mermaid-svg-G3LZ0RdYzvBBm3Fr span{fill:#333;color:#333;}#mermaid-svg-G3LZ0RdYzvBBm3Fr .node rect,#mermaid-svg-G3LZ0RdYzvBBm3Fr .node circle,#mermaid-svg-G3LZ0RdYzvBBm3Fr .node ellipse,#mermaid-svg-G3LZ0RdYzvBBm3Fr .node polygon,#mermaid-svg-G3LZ0RdYzvBBm3Fr .node path{fill:#ECECFF;stroke:#9370DB;stroke-width:1px;}#mermaid-svg-G3LZ0RdYzvBBm3Fr .rough-node .label text,#mermaid-svg-G3LZ0RdYzvBBm3Fr .node .label text,#mermaid-svg-G3LZ0RdYzvBBm3Fr .image-shape .label,#mermaid-svg-G3LZ0RdYzvBBm3Fr .icon-shape .label{text-anchor:middle;}#mermaid-svg-G3LZ0RdYzvBBm3Fr .node .katex path{fill:#000;stroke:#000;stroke-width:1px;}#mermaid-svg-G3LZ0RdYzvBBm3Fr .rough-node .label,#mermaid-svg-G3LZ0RdYzvBBm3Fr .node .label,#mermaid-svg-G3LZ0RdYzvBBm3Fr .image-shape .label,#mermaid-svg-G3LZ0RdYzvBBm3Fr .icon-shape .label{text-align:center;}#mermaid-svg-G3LZ0RdYzvBBm3Fr .node.clickable{cursor:pointer;}#mermaid-svg-G3LZ0RdYzvBBm3Fr .root .anchor path{fill:#333333!important;stroke-width:0;stroke:#333333;}#mermaid-svg-G3LZ0RdYzvBBm3Fr .arrowheadPath{fill:#333333;}#mermaid-svg-G3LZ0RdYzvBBm3Fr .edgePath .path{stroke:#333333;stroke-width:2.0px;}#mermaid-svg-G3LZ0RdYzvBBm3Fr .flowchart-link{stroke:#333333;fill:none;}#mermaid-svg-G3LZ0RdYzvBBm3Fr .edgeLabel{background-color:rgba(232,232,232, 0.8);text-align:center;}#mermaid-svg-G3LZ0RdYzvBBm3Fr .edgeLabel p{background-color:rgba(232,232,232, 0.8);}#mermaid-svg-G3LZ0RdYzvBBm3Fr .edgeLabel rect{opacity:0.5;background-color:rgba(232,232,232, 0.8);fill:rgba(232,232,232, 0.8);}#mermaid-svg-G3LZ0RdYzvBBm3Fr .labelBkg{background-color:rgba(232, 232, 232, 0.5);}#mermaid-svg-G3LZ0RdYzvBBm3Fr .cluster rect{fill:#ffffde;stroke:#aaaa33;stroke-width:1px;}#mermaid-svg-G3LZ0RdYzvBBm3Fr .cluster text{fill:#333;}#mermaid-svg-G3LZ0RdYzvBBm3Fr .cluster span{color:#333;}#mermaid-svg-G3LZ0RdYzvBBm3Fr div.mermaidTooltip{position:absolute;text-align:center;max-width:200px;padding:2px;font-family:"trebuchet ms",verdana,arial,sans-serif;font-size:12px;background:hsl(80, 100%, 96.2745098039%);border:1px solid #aaaa33;border-radius:2px;pointer-events:none;z-index:100;}#mermaid-svg-G3LZ0RdYzvBBm3Fr .flowchartTitleText{text-anchor:middle;font-size:18px;fill:#333;}#mermaid-svg-G3LZ0RdYzvBBm3Fr rect.text{fill:none;stroke-width:0;}#mermaid-svg-G3LZ0RdYzvBBm3Fr .icon-shape,#mermaid-svg-G3LZ0RdYzvBBm3Fr .image-shape{background-color:rgba(232,232,232, 0.8);text-align:center;}#mermaid-svg-G3LZ0RdYzvBBm3Fr .icon-shape p,#mermaid-svg-G3LZ0RdYzvBBm3Fr .image-shape p{background-color:rgba(232,232,232, 0.8);padding:2px;}#mermaid-svg-G3LZ0RdYzvBBm3Fr .icon-shape .label rect,#mermaid-svg-G3LZ0RdYzvBBm3Fr .image-shape .label rect{opacity:0.5;background-color:rgba(232,232,232, 0.8);fill:rgba(232,232,232, 0.8);}#mermaid-svg-G3LZ0RdYzvBBm3Fr .label-icon{display:inline-block;height:1em;overflow:visible;vertical-align:-0.125em;}#mermaid-svg-G3LZ0RdYzvBBm3Fr .node .label-icon path{fill:currentColor;stroke:revert;stroke-width:revert;}#mermaid-svg-G3LZ0RdYzvBBm3Fr :root{--mermaid-font-family:"trebuchet ms",verdana,arial,sans-serif;} 前端 HTTP 请求
Controller

@PostMapping
接收 @RequestBody Map
调用 Service
返回 Service 的结果

Controller 层的职责极其简单:

  1. 定义 URL 路径映射
  2. 接收请求体(Map<String, Object>
  3. 调用对应的 Service 方法
  4. 返回 Service 的结果

所有业务逻辑(参数兜底、认证头设置、异常处理)都在 Service 层完成。

7.2 OpenAiCompletionsController

java 复制代码
package com.lihaozhe.controller;

import com.lihaozhe.service.OpenAiCompletionsService;
import lombok.RequiredArgsConstructor;
import org.springframework.http.MediaType;
import org.springframework.http.codec.ServerSentEvent;
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;
import reactor.core.publisher.Flux;

import java.util.Map;

/**
 * OpenAI Chat Completions 协议接口
 *
 * <p>前端按该协议拼装完整请求体,本控制器原样接收并透传:
 * <ul>
 *   <li>POST /api/openai-completions/chat --- 非流式,返回完整 JSON</li>
 *   <li>POST /api/openai-completions/chat/stream --- 流式,返回 SSE 事件流</li>
 * </ul>
 *
 * @author 李昊哲
 * @since 1.0.0
 */
@RestController
@RequestMapping("/api/openai-completions")
@RequiredArgsConstructor
public class OpenAiCompletionsController {

    private final OpenAiCompletionsService service;

    @PostMapping("/chat")
    public Map<String, Object> chat(@RequestBody Map<String, Object> body) {
        return service.chat(body);
    }

    @PostMapping(value = "/chat/stream",
                 produces = MediaType.TEXT_EVENT_STREAM_VALUE)
    public Flux<ServerSentEvent<String>> chatStream(
            @RequestBody Map<String, Object> body) {
        return service.chatStream(body);
    }
}

初学者说明------注解与方法参数逐项拆解(7.3、7.4 与本节结构完全相同,仅类名、路径、Service 不同):

注解 / 成员 作用 初学者要点
@RestController 声明 REST 接口类:返回值直接作为响应体(JSON / SSE),不是跳转页面 若写成 @Controller,返回值会被当成视图名导致 404
@RequestMapping("/api/openai-completions") 类级前缀:本类所有端点的 URL 都以它开头 配合方法级注解拼出完整路径
@RequiredArgsConstructor(Lombok) 为所有 final 字段生成构造器 private final OpenAiCompletionsService service; 因此能在构造时被 Spring 注入
private final OpenAiCompletionsService service 待注入的依赖 final 是构造器注入的前提
@PostMapping("/chat") 把 POST 请求映射到方法,完整路径 = 类前缀 + /chat 只接受 POST,用 GET 访问会返回 405
@RequestBody Map<String, Object> body 请求体的 JSON 反序列化成 Map 传入方法 没有此注解,参数会被当成 URL 查询参数处理;用 Map 而非 DTO 是为了原样透传,不做字段校验
Map<String, Object>(返回值) Spring 自动序列化成 JSON 响应体 响应头自动为 application/json
@PostMapping(value = "/chat/stream", produces = ...) 映射流式端点;produces 声明响应体类型 MediaType.TEXT_EVENT_STREAM_VALUE"text/event-stream",响应头据此设置,浏览器 EventSource 才认
Flux<ServerSentEvent<String>>(返回值) Spring 订阅该流,每产生一帧就写回一条 SSE 前提是 produces 已声明为 SSE;流结束时 HTTP 响应才结束

7.3 AnthropicMessagesController

java 复制代码
package com.lihaozhe.controller;

import com.lihaozhe.service.AnthropicMessagesService;
import lombok.RequiredArgsConstructor;
import org.springframework.http.MediaType;
import org.springframework.http.codec.ServerSentEvent;
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;
import reactor.core.publisher.Flux;

import java.util.Map;

/**
 * Anthropic Messages 协议接口
 *
 * @author 李昊哲
 * @since 1.0.0
 */
@RestController
@RequestMapping("/api/anthropic-messages")
@RequiredArgsConstructor
public class AnthropicMessagesController {

    private final AnthropicMessagesService service;

    @PostMapping("/chat")
    public Map<String, Object> chat(@RequestBody Map<String, Object> body) {
        return service.chat(body);
    }

    @PostMapping(value = "/chat/stream",
                 produces = MediaType.TEXT_EVENT_STREAM_VALUE)
    public Flux<ServerSentEvent<String>> chatStream(
            @RequestBody Map<String, Object> body) {
        return service.chatStream(body);
    }
}

7.4 OpenAiResponsesController

java 复制代码
package com.lihaozhe.controller;

import com.lihaozhe.service.OpenAiResponsesService;
import lombok.RequiredArgsConstructor;
import org.springframework.http.MediaType;
import org.springframework.http.codec.ServerSentEvent;
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;
import reactor.core.publisher.Flux;

import java.util.Map;

/**
 * OpenAI Responses 协议接口
 *
 * @author 李昊哲
 * @since 1.0.0
 */
@RestController
@RequestMapping("/api/openai-responses")
@RequiredArgsConstructor
public class OpenAiResponsesController {

    private final OpenAiResponsesService service;

    @PostMapping("/chat")
    public Map<String, Object> chat(@RequestBody Map<String, Object> body) {
        return service.chat(body);
    }

    @PostMapping(value = "/chat/stream",
                 produces = MediaType.TEXT_EVENT_STREAM_VALUE)
    public Flux<ServerSentEvent<String>> chatStream(
            @RequestBody Map<String, Object> body) {
        return service.chatStream(body);
    }
}

7.5 端点一览表

协议 非流式端点 流式端点
OpenAI Completions POST /api/openai-completions/chat POST /api/openai-completions/chat/stream
OpenAI Responses POST /api/openai-responses/chat POST /api/openai-responses/chat/stream
Anthropic Messages POST /api/anthropic-messages/chat POST /api/anthropic-messages/chat/stream

衔接下一章:Controller 调用 Service 时,如果上游返回错误(4xx/5xx)或网络异常,需要统一处理。下一章将讲解异常处理体系。


第八章 异常处理体系

本章定位:健壮的代理服务不能只有"快乐路径"。当上游限流、超时、返回错误时,需要有统一的异常处理机制。本章将讲解自定义异常类和全局异常处理器。

8.1 异常处理流程

#mermaid-svg-fxEkfUPY8dMWHVpC{font-family:"trebuchet ms",verdana,arial,sans-serif;font-size:16px;fill:#333;}@keyframes edge-animation-frame{from{stroke-dashoffset:0;}}@keyframes dash{to{stroke-dashoffset:0;}}#mermaid-svg-fxEkfUPY8dMWHVpC .edge-animation-slow{stroke-dasharray:9,5!important;stroke-dashoffset:900;animation:dash 50s linear infinite;stroke-linecap:round;}#mermaid-svg-fxEkfUPY8dMWHVpC .edge-animation-fast{stroke-dasharray:9,5!important;stroke-dashoffset:900;animation:dash 20s linear infinite;stroke-linecap:round;}#mermaid-svg-fxEkfUPY8dMWHVpC .error-icon{fill:#552222;}#mermaid-svg-fxEkfUPY8dMWHVpC .error-text{fill:#552222;stroke:#552222;}#mermaid-svg-fxEkfUPY8dMWHVpC .edge-thickness-normal{stroke-width:1px;}#mermaid-svg-fxEkfUPY8dMWHVpC .edge-thickness-thick{stroke-width:3.5px;}#mermaid-svg-fxEkfUPY8dMWHVpC .edge-pattern-solid{stroke-dasharray:0;}#mermaid-svg-fxEkfUPY8dMWHVpC .edge-thickness-invisible{stroke-width:0;fill:none;}#mermaid-svg-fxEkfUPY8dMWHVpC .edge-pattern-dashed{stroke-dasharray:3;}#mermaid-svg-fxEkfUPY8dMWHVpC .edge-pattern-dotted{stroke-dasharray:2;}#mermaid-svg-fxEkfUPY8dMWHVpC .marker{fill:#333333;stroke:#333333;}#mermaid-svg-fxEkfUPY8dMWHVpC .marker.cross{stroke:#333333;}#mermaid-svg-fxEkfUPY8dMWHVpC svg{font-family:"trebuchet ms",verdana,arial,sans-serif;font-size:16px;}#mermaid-svg-fxEkfUPY8dMWHVpC p{margin:0;}#mermaid-svg-fxEkfUPY8dMWHVpC .label{font-family:"trebuchet ms",verdana,arial,sans-serif;color:#333;}#mermaid-svg-fxEkfUPY8dMWHVpC .cluster-label text{fill:#333;}#mermaid-svg-fxEkfUPY8dMWHVpC .cluster-label span{color:#333;}#mermaid-svg-fxEkfUPY8dMWHVpC .cluster-label span p{background-color:transparent;}#mermaid-svg-fxEkfUPY8dMWHVpC .label text,#mermaid-svg-fxEkfUPY8dMWHVpC span{fill:#333;color:#333;}#mermaid-svg-fxEkfUPY8dMWHVpC .node rect,#mermaid-svg-fxEkfUPY8dMWHVpC .node circle,#mermaid-svg-fxEkfUPY8dMWHVpC .node ellipse,#mermaid-svg-fxEkfUPY8dMWHVpC .node polygon,#mermaid-svg-fxEkfUPY8dMWHVpC .node path{fill:#ECECFF;stroke:#9370DB;stroke-width:1px;}#mermaid-svg-fxEkfUPY8dMWHVpC .rough-node .label text,#mermaid-svg-fxEkfUPY8dMWHVpC .node .label text,#mermaid-svg-fxEkfUPY8dMWHVpC .image-shape .label,#mermaid-svg-fxEkfUPY8dMWHVpC .icon-shape .label{text-anchor:middle;}#mermaid-svg-fxEkfUPY8dMWHVpC .node .katex path{fill:#000;stroke:#000;stroke-width:1px;}#mermaid-svg-fxEkfUPY8dMWHVpC .rough-node .label,#mermaid-svg-fxEkfUPY8dMWHVpC .node .label,#mermaid-svg-fxEkfUPY8dMWHVpC .image-shape .label,#mermaid-svg-fxEkfUPY8dMWHVpC .icon-shape .label{text-align:center;}#mermaid-svg-fxEkfUPY8dMWHVpC .node.clickable{cursor:pointer;}#mermaid-svg-fxEkfUPY8dMWHVpC .root .anchor path{fill:#333333!important;stroke-width:0;stroke:#333333;}#mermaid-svg-fxEkfUPY8dMWHVpC .arrowheadPath{fill:#333333;}#mermaid-svg-fxEkfUPY8dMWHVpC .edgePath .path{stroke:#333333;stroke-width:2.0px;}#mermaid-svg-fxEkfUPY8dMWHVpC .flowchart-link{stroke:#333333;fill:none;}#mermaid-svg-fxEkfUPY8dMWHVpC .edgeLabel{background-color:rgba(232,232,232, 0.8);text-align:center;}#mermaid-svg-fxEkfUPY8dMWHVpC .edgeLabel p{background-color:rgba(232,232,232, 0.8);}#mermaid-svg-fxEkfUPY8dMWHVpC .edgeLabel rect{opacity:0.5;background-color:rgba(232,232,232, 0.8);fill:rgba(232,232,232, 0.8);}#mermaid-svg-fxEkfUPY8dMWHVpC .labelBkg{background-color:rgba(232, 232, 232, 0.5);}#mermaid-svg-fxEkfUPY8dMWHVpC .cluster rect{fill:#ffffde;stroke:#aaaa33;stroke-width:1px;}#mermaid-svg-fxEkfUPY8dMWHVpC .cluster text{fill:#333;}#mermaid-svg-fxEkfUPY8dMWHVpC .cluster span{color:#333;}#mermaid-svg-fxEkfUPY8dMWHVpC div.mermaidTooltip{position:absolute;text-align:center;max-width:200px;padding:2px;font-family:"trebuchet ms",verdana,arial,sans-serif;font-size:12px;background:hsl(80, 100%, 96.2745098039%);border:1px solid #aaaa33;border-radius:2px;pointer-events:none;z-index:100;}#mermaid-svg-fxEkfUPY8dMWHVpC .flowchartTitleText{text-anchor:middle;font-size:18px;fill:#333;}#mermaid-svg-fxEkfUPY8dMWHVpC rect.text{fill:none;stroke-width:0;}#mermaid-svg-fxEkfUPY8dMWHVpC .icon-shape,#mermaid-svg-fxEkfUPY8dMWHVpC .image-shape{background-color:rgba(232,232,232, 0.8);text-align:center;}#mermaid-svg-fxEkfUPY8dMWHVpC .icon-shape p,#mermaid-svg-fxEkfUPY8dMWHVpC .image-shape p{background-color:rgba(232,232,232, 0.8);padding:2px;}#mermaid-svg-fxEkfUPY8dMWHVpC .icon-shape .label rect,#mermaid-svg-fxEkfUPY8dMWHVpC .image-shape .label rect{opacity:0.5;background-color:rgba(232,232,232, 0.8);fill:rgba(232,232,232, 0.8);}#mermaid-svg-fxEkfUPY8dMWHVpC .label-icon{display:inline-block;height:1em;overflow:visible;vertical-align:-0.125em;}#mermaid-svg-fxEkfUPY8dMWHVpC .node .label-icon path{fill:currentColor;stroke:revert;stroke-width:revert;}#mermaid-svg-fxEkfUPY8dMWHVpC :root{--mermaid-font-family:"trebuchet ms",verdana,arial,sans-serif;} 200 OK
429/503

限流/不可用
其他 4xx/5xx
网络异常
Service 调用上游
上游响应?
正常返回
捕获 HttpStatusCodeException
捕获 HttpStatusCodeException
捕获 WebClientException
new ApiException

(带 retryAfter)
new ApiException

(无 retryAfter)
GlobalExceptionHandler
返回标准 JSON 错误响应

8.2 ApiException------业务异常基类

java 复制代码
package com.lihaozhe.exception;

import lombok.Getter;

/**
 * 业务异常基类 ------ 包装 LLM API 调用过程中产生的所有错误
 *
 * <h2>HTTP 状态码约定</h2>
 * <pre>
 * 429 Too Many Requests --- 上游限流,客户端应按 Retry-After 等待后重试
 * 502 Bad Gateway       --- 上游错误/网络异常/响应非 JSON
 * 503 Service Unavail.  --- 上游服务不可用
 * 504 Gateway Timeout   --- 请求超时
 * 500 Internal Error    --- 业务/服务端错误
 * 400 Bad Request       --- 入参错误
 * </pre>
 *
 * @author 李昊哲
 * @since 1.0.0
 */
@Getter
public class ApiException extends RuntimeException {

    /** HTTP 响应状态码(透传给前端) */
    private final int status;

    /** 出错的 Provider 友好名(如 "OpenAI Completions"),便于日志聚合 */
    private final String provider;

    /**
     * 上游返回的 Retry-After 秒数(仅 429/503 时有值)
     *
     * <p>前端可据此显示"请在 X 秒后重试",或自动定时重试。
     */
    private final Integer retryAfter;

    /** 构造业务异常(带 Retry-After) */
    public ApiException(String message, String provider,
                        int status, Integer retryAfter) {
        super(message);
        this.provider = provider;
        this.status = status;
        this.retryAfter = retryAfter;
    }

    /** 构造业务异常(无 Retry-After) */
    public ApiException(String message, String provider, int status) {
        this(message, provider, status, null);
    }
}

初学者说明------本类方法与参数

成员 / 参数 含义 初学者要点
extends RuntimeException 继承"运行时异常" 运行时异常不强制 try-catch 或 throws 声明,代码更简洁;受检异常(如 IOException)则必须显式处理
@Getter(Lombok) 生成 getStatus() / getProvider() / getRetryAfter() GlobalExceptionHandler 靠这些 getter 取值
super(message) 把错误文案传给父类 之后 e.getMessage() 就能取到它
参数 message 给前端看的错误描述 通常拼上上游状态码和错误原文
参数 provider 出错的协议标识(如 "openai-completions" 便于前端区分哪个协议出了问题、日志按协议聚合
参数 status 返给前端的 HTTP 状态码 不是随便填:429 限流、502 上游错误、503 不可用(见类注释约定)
参数 retryAfter 建议重试秒数,可为 null Integer 是包装类型才能存 null;三参构造器内部 this(..., null) 转调四参构造器,避免重复代码

8.3 GlobalExceptionHandler------全局异常处理器

java 复制代码
package com.lihaozhe.exception;

import org.springframework.http.MediaType;
import org.springframework.http.ResponseEntity;
import org.springframework.web.bind.annotation.ExceptionHandler;
import org.springframework.web.bind.annotation.RestControllerAdvice;

import java.util.LinkedHashMap;
import java.util.Map;

/**
 * 全局异常处理器 ------ 捕获 ApiException 转为 JSON 响应
 *
 * <p>429/503 场景额外返回 Retry-After 响应头与 retry_after 字段,
 * 方便前端据此展示重试倒计时或自动定时重试。
 *
 * @author 李昊哲
 * @since 1.0.0
 */
@RestControllerAdvice
public class GlobalExceptionHandler {

    @ExceptionHandler(ApiException.class)
    public ResponseEntity<Map<String, Object>> handleApiException(ApiException e) {
        Map<String, Object> body = new LinkedHashMap<>();
        body.put("error", e.getMessage());
        body.put("provider", e.getProvider() == null ? "unknown" : e.getProvider());
        body.put("status", e.getStatus());

        ResponseEntity.BodyBuilder builder = ResponseEntity.status(e.getStatus());

        if ((e.getStatus() == 429 || e.getStatus() == 503)
                && e.getRetryAfter() != null) {
            body.put("retry_after", e.getRetryAfter());
            builder.header("Retry-After", String.valueOf(e.getRetryAfter()));
        }

        return builder.body(body);
    }

    @ExceptionHandler(IllegalArgumentException.class)
    public ResponseEntity<Map<String, Object>> handleBadRequest(
            IllegalArgumentException e) {
        return ResponseEntity.badRequest().body(
                Map.of("error", e.getMessage(), "status", 400));
    }

    @ExceptionHandler(Exception.class)
    public ResponseEntity<Map<String, Object>> handleUnknown(Exception e) {
        return ResponseEntity.internalServerError()
                .contentType(MediaType.APPLICATION_JSON)
                .body(Map.of(
                        "error", "服务器内部错误: " + e.getMessage(),
                        "status", 500));
    }
}

初学者说明------本类注解与方法

注解 / 方法 作用 初学者要点
@RestControllerAdvice 全局生效的"异常拦截器":任何 Controller 抛出的异常都会流经这里 不用在每个 Controller 里写 try-catch
@ExceptionHandler(ApiException.class) 标明本方法只处理 ApiException 类型的异常 同类中可写多个方法处理不同异常;Spring 按异常类型最匹配的原则选择
handleApiException(ApiException e) 参数就是被捕获的异常对象 方法名随意,Spring 只看注解
new LinkedHashMap<>() 有序 Map:body 字段按 put 顺序输出 若用 HashMap,JSON 字段顺序会乱(不影响功能,影响可读性)
e.getStatus() / e.getProvider() 取 ApiException 的字段 @Getter 生成(见 8.2)
ResponseEntity.status(e.getStatus()) 以指定状态码构建响应 返回 BodyBuilder,可继续链式调用
builder.header("Retry-After", String.valueOf(...)) 追加响应头 String.valueOf 把 Integer 转成字符串(响应头只能是字符串)
builder.body(body) 最终产出 ResponseEntity(状态码 + 头 + 体) 三要素齐全后一次性返给前端
ResponseEntity.badRequest() 400 的快捷方法 对应还有 .internalServerError()(500)等
Map.of(k, v, ...) 不可变小 Map 的快捷构造 最多 10 对键值;顺序不保证(这里只有两个字段,无需顺序)
三个方法的优先级 ApiExceptionIllegalArgumentExceptionException 前两个没匹配上才落到兜底的 Exception,避免所有错误都变 500

错误响应示例

json 复制代码
{
  "error": "上游 429 Too Many Requests: Rate limit exceeded",
  "provider": "openai-completions",
  "status": 429,
  "retry_after": 30
}

衔接下一章:后端架构已经完整。最后一块拼图是前端------用户在页面上输入问题,前端如何按三种协议拼装请求体、如何解析非流式响应和流式 SSE 事件。下一章将进入前端交互的世界。


第九章 前端------对话交互界面

本章定位:前端是整个项目的"门面"------用户直接交互的部分。本章将讲解前端如何按三种协议拼装请求体、如何解析非流式响应、如何处理流式 SSE 事件。app.js 刻意不做任何封装,所有逻辑平铺直叙,便于学习。

9.1 页面结构

#mermaid-svg-5biu3KiWW1MHlh4I{font-family:"trebuchet ms",verdana,arial,sans-serif;font-size:16px;fill:#333;}@keyframes edge-animation-frame{from{stroke-dashoffset:0;}}@keyframes dash{to{stroke-dashoffset:0;}}#mermaid-svg-5biu3KiWW1MHlh4I .edge-animation-slow{stroke-dasharray:9,5!important;stroke-dashoffset:900;animation:dash 50s linear infinite;stroke-linecap:round;}#mermaid-svg-5biu3KiWW1MHlh4I .edge-animation-fast{stroke-dasharray:9,5!important;stroke-dashoffset:900;animation:dash 20s linear infinite;stroke-linecap:round;}#mermaid-svg-5biu3KiWW1MHlh4I .error-icon{fill:#552222;}#mermaid-svg-5biu3KiWW1MHlh4I .error-text{fill:#552222;stroke:#552222;}#mermaid-svg-5biu3KiWW1MHlh4I .edge-thickness-normal{stroke-width:1px;}#mermaid-svg-5biu3KiWW1MHlh4I .edge-thickness-thick{stroke-width:3.5px;}#mermaid-svg-5biu3KiWW1MHlh4I .edge-pattern-solid{stroke-dasharray:0;}#mermaid-svg-5biu3KiWW1MHlh4I .edge-thickness-invisible{stroke-width:0;fill:none;}#mermaid-svg-5biu3KiWW1MHlh4I .edge-pattern-dashed{stroke-dasharray:3;}#mermaid-svg-5biu3KiWW1MHlh4I .edge-pattern-dotted{stroke-dasharray:2;}#mermaid-svg-5biu3KiWW1MHlh4I .marker{fill:#333333;stroke:#333333;}#mermaid-svg-5biu3KiWW1MHlh4I .marker.cross{stroke:#333333;}#mermaid-svg-5biu3KiWW1MHlh4I svg{font-family:"trebuchet ms",verdana,arial,sans-serif;font-size:16px;}#mermaid-svg-5biu3KiWW1MHlh4I p{margin:0;}#mermaid-svg-5biu3KiWW1MHlh4I .label{font-family:"trebuchet ms",verdana,arial,sans-serif;color:#333;}#mermaid-svg-5biu3KiWW1MHlh4I .cluster-label text{fill:#333;}#mermaid-svg-5biu3KiWW1MHlh4I .cluster-label span{color:#333;}#mermaid-svg-5biu3KiWW1MHlh4I .cluster-label span p{background-color:transparent;}#mermaid-svg-5biu3KiWW1MHlh4I .label text,#mermaid-svg-5biu3KiWW1MHlh4I span{fill:#333;color:#333;}#mermaid-svg-5biu3KiWW1MHlh4I .node rect,#mermaid-svg-5biu3KiWW1MHlh4I .node circle,#mermaid-svg-5biu3KiWW1MHlh4I .node ellipse,#mermaid-svg-5biu3KiWW1MHlh4I .node polygon,#mermaid-svg-5biu3KiWW1MHlh4I .node path{fill:#ECECFF;stroke:#9370DB;stroke-width:1px;}#mermaid-svg-5biu3KiWW1MHlh4I .rough-node .label text,#mermaid-svg-5biu3KiWW1MHlh4I .node .label text,#mermaid-svg-5biu3KiWW1MHlh4I .image-shape .label,#mermaid-svg-5biu3KiWW1MHlh4I .icon-shape .label{text-anchor:middle;}#mermaid-svg-5biu3KiWW1MHlh4I .node .katex path{fill:#000;stroke:#000;stroke-width:1px;}#mermaid-svg-5biu3KiWW1MHlh4I .rough-node .label,#mermaid-svg-5biu3KiWW1MHlh4I .node .label,#mermaid-svg-5biu3KiWW1MHlh4I .image-shape .label,#mermaid-svg-5biu3KiWW1MHlh4I .icon-shape .label{text-align:center;}#mermaid-svg-5biu3KiWW1MHlh4I .node.clickable{cursor:pointer;}#mermaid-svg-5biu3KiWW1MHlh4I .root .anchor path{fill:#333333!important;stroke-width:0;stroke:#333333;}#mermaid-svg-5biu3KiWW1MHlh4I .arrowheadPath{fill:#333333;}#mermaid-svg-5biu3KiWW1MHlh4I .edgePath .path{stroke:#333333;stroke-width:2.0px;}#mermaid-svg-5biu3KiWW1MHlh4I .flowchart-link{stroke:#333333;fill:none;}#mermaid-svg-5biu3KiWW1MHlh4I .edgeLabel{background-color:rgba(232,232,232, 0.8);text-align:center;}#mermaid-svg-5biu3KiWW1MHlh4I .edgeLabel p{background-color:rgba(232,232,232, 0.8);}#mermaid-svg-5biu3KiWW1MHlh4I .edgeLabel rect{opacity:0.5;background-color:rgba(232,232,232, 0.8);fill:rgba(232,232,232, 0.8);}#mermaid-svg-5biu3KiWW1MHlh4I .labelBkg{background-color:rgba(232, 232, 232, 0.5);}#mermaid-svg-5biu3KiWW1MHlh4I .cluster rect{fill:#ffffde;stroke:#aaaa33;stroke-width:1px;}#mermaid-svg-5biu3KiWW1MHlh4I .cluster text{fill:#333;}#mermaid-svg-5biu3KiWW1MHlh4I .cluster span{color:#333;}#mermaid-svg-5biu3KiWW1MHlh4I div.mermaidTooltip{position:absolute;text-align:center;max-width:200px;padding:2px;font-family:"trebuchet ms",verdana,arial,sans-serif;font-size:12px;background:hsl(80, 100%, 96.2745098039%);border:1px solid #aaaa33;border-radius:2px;pointer-events:none;z-index:100;}#mermaid-svg-5biu3KiWW1MHlh4I .flowchartTitleText{text-anchor:middle;font-size:18px;fill:#333;}#mermaid-svg-5biu3KiWW1MHlh4I rect.text{fill:none;stroke-width:0;}#mermaid-svg-5biu3KiWW1MHlh4I .icon-shape,#mermaid-svg-5biu3KiWW1MHlh4I .image-shape{background-color:rgba(232,232,232, 0.8);text-align:center;}#mermaid-svg-5biu3KiWW1MHlh4I .icon-shape p,#mermaid-svg-5biu3KiWW1MHlh4I .image-shape p{background-color:rgba(232,232,232, 0.8);padding:2px;}#mermaid-svg-5biu3KiWW1MHlh4I .icon-shape .label rect,#mermaid-svg-5biu3KiWW1MHlh4I .image-shape .label rect{opacity:0.5;background-color:rgba(232,232,232, 0.8);fill:rgba(232,232,232, 0.8);}#mermaid-svg-5biu3KiWW1MHlh4I .label-icon{display:inline-block;height:1em;overflow:visible;vertical-align:-0.125em;}#mermaid-svg-5biu3KiWW1MHlh4I .node .label-icon path{fill:currentColor;stroke:revert;stroke-width:revert;}#mermaid-svg-5biu3KiWW1MHlh4I :root{--mermaid-font-family:"trebuchet ms",verdana,arial,sans-serif;} 页面骨架 (Grid 四行布局)
头部

长白山意象 + 吉字印章
控制条

协议选择 + 模式切换
消息区

对话历史
输入区

文本框 + 发送按钮

9.2 前端完整交互流程

#mermaid-svg-wBnmI6gZaQXNXvH3{font-family:"trebuchet ms",verdana,arial,sans-serif;font-size:16px;fill:#333;}@keyframes edge-animation-frame{from{stroke-dashoffset:0;}}@keyframes dash{to{stroke-dashoffset:0;}}#mermaid-svg-wBnmI6gZaQXNXvH3 .edge-animation-slow{stroke-dasharray:9,5!important;stroke-dashoffset:900;animation:dash 50s linear infinite;stroke-linecap:round;}#mermaid-svg-wBnmI6gZaQXNXvH3 .edge-animation-fast{stroke-dasharray:9,5!important;stroke-dashoffset:900;animation:dash 20s linear infinite;stroke-linecap:round;}#mermaid-svg-wBnmI6gZaQXNXvH3 .error-icon{fill:#552222;}#mermaid-svg-wBnmI6gZaQXNXvH3 .error-text{fill:#552222;stroke:#552222;}#mermaid-svg-wBnmI6gZaQXNXvH3 .edge-thickness-normal{stroke-width:1px;}#mermaid-svg-wBnmI6gZaQXNXvH3 .edge-thickness-thick{stroke-width:3.5px;}#mermaid-svg-wBnmI6gZaQXNXvH3 .edge-pattern-solid{stroke-dasharray:0;}#mermaid-svg-wBnmI6gZaQXNXvH3 .edge-thickness-invisible{stroke-width:0;fill:none;}#mermaid-svg-wBnmI6gZaQXNXvH3 .edge-pattern-dashed{stroke-dasharray:3;}#mermaid-svg-wBnmI6gZaQXNXvH3 .edge-pattern-dotted{stroke-dasharray:2;}#mermaid-svg-wBnmI6gZaQXNXvH3 .marker{fill:#333333;stroke:#333333;}#mermaid-svg-wBnmI6gZaQXNXvH3 .marker.cross{stroke:#333333;}#mermaid-svg-wBnmI6gZaQXNXvH3 svg{font-family:"trebuchet ms",verdana,arial,sans-serif;font-size:16px;}#mermaid-svg-wBnmI6gZaQXNXvH3 p{margin:0;}#mermaid-svg-wBnmI6gZaQXNXvH3 .label{font-family:"trebuchet ms",verdana,arial,sans-serif;color:#333;}#mermaid-svg-wBnmI6gZaQXNXvH3 .cluster-label text{fill:#333;}#mermaid-svg-wBnmI6gZaQXNXvH3 .cluster-label span{color:#333;}#mermaid-svg-wBnmI6gZaQXNXvH3 .cluster-label span p{background-color:transparent;}#mermaid-svg-wBnmI6gZaQXNXvH3 .label text,#mermaid-svg-wBnmI6gZaQXNXvH3 span{fill:#333;color:#333;}#mermaid-svg-wBnmI6gZaQXNXvH3 .node rect,#mermaid-svg-wBnmI6gZaQXNXvH3 .node circle,#mermaid-svg-wBnmI6gZaQXNXvH3 .node ellipse,#mermaid-svg-wBnmI6gZaQXNXvH3 .node polygon,#mermaid-svg-wBnmI6gZaQXNXvH3 .node path{fill:#ECECFF;stroke:#9370DB;stroke-width:1px;}#mermaid-svg-wBnmI6gZaQXNXvH3 .rough-node .label text,#mermaid-svg-wBnmI6gZaQXNXvH3 .node .label text,#mermaid-svg-wBnmI6gZaQXNXvH3 .image-shape .label,#mermaid-svg-wBnmI6gZaQXNXvH3 .icon-shape .label{text-anchor:middle;}#mermaid-svg-wBnmI6gZaQXNXvH3 .node .katex path{fill:#000;stroke:#000;stroke-width:1px;}#mermaid-svg-wBnmI6gZaQXNXvH3 .rough-node .label,#mermaid-svg-wBnmI6gZaQXNXvH3 .node .label,#mermaid-svg-wBnmI6gZaQXNXvH3 .image-shape .label,#mermaid-svg-wBnmI6gZaQXNXvH3 .icon-shape .label{text-align:center;}#mermaid-svg-wBnmI6gZaQXNXvH3 .node.clickable{cursor:pointer;}#mermaid-svg-wBnmI6gZaQXNXvH3 .root .anchor path{fill:#333333!important;stroke-width:0;stroke:#333333;}#mermaid-svg-wBnmI6gZaQXNXvH3 .arrowheadPath{fill:#333333;}#mermaid-svg-wBnmI6gZaQXNXvH3 .edgePath .path{stroke:#333333;stroke-width:2.0px;}#mermaid-svg-wBnmI6gZaQXNXvH3 .flowchart-link{stroke:#333333;fill:none;}#mermaid-svg-wBnmI6gZaQXNXvH3 .edgeLabel{background-color:rgba(232,232,232, 0.8);text-align:center;}#mermaid-svg-wBnmI6gZaQXNXvH3 .edgeLabel p{background-color:rgba(232,232,232, 0.8);}#mermaid-svg-wBnmI6gZaQXNXvH3 .edgeLabel rect{opacity:0.5;background-color:rgba(232,232,232, 0.8);fill:rgba(232,232,232, 0.8);}#mermaid-svg-wBnmI6gZaQXNXvH3 .labelBkg{background-color:rgba(232, 232, 232, 0.5);}#mermaid-svg-wBnmI6gZaQXNXvH3 .cluster rect{fill:#ffffde;stroke:#aaaa33;stroke-width:1px;}#mermaid-svg-wBnmI6gZaQXNXvH3 .cluster text{fill:#333;}#mermaid-svg-wBnmI6gZaQXNXvH3 .cluster span{color:#333;}#mermaid-svg-wBnmI6gZaQXNXvH3 div.mermaidTooltip{position:absolute;text-align:center;max-width:200px;padding:2px;font-family:"trebuchet ms",verdana,arial,sans-serif;font-size:12px;background:hsl(80, 100%, 96.2745098039%);border:1px solid #aaaa33;border-radius:2px;pointer-events:none;z-index:100;}#mermaid-svg-wBnmI6gZaQXNXvH3 .flowchartTitleText{text-anchor:middle;font-size:18px;fill:#333;}#mermaid-svg-wBnmI6gZaQXNXvH3 rect.text{fill:none;stroke-width:0;}#mermaid-svg-wBnmI6gZaQXNXvH3 .icon-shape,#mermaid-svg-wBnmI6gZaQXNXvH3 .image-shape{background-color:rgba(232,232,232, 0.8);text-align:center;}#mermaid-svg-wBnmI6gZaQXNXvH3 .icon-shape p,#mermaid-svg-wBnmI6gZaQXNXvH3 .image-shape p{background-color:rgba(232,232,232, 0.8);padding:2px;}#mermaid-svg-wBnmI6gZaQXNXvH3 .icon-shape .label rect,#mermaid-svg-wBnmI6gZaQXNXvH3 .image-shape .label rect{opacity:0.5;background-color:rgba(232,232,232, 0.8);fill:rgba(232,232,232, 0.8);}#mermaid-svg-wBnmI6gZaQXNXvH3 .label-icon{display:inline-block;height:1em;overflow:visible;vertical-align:-0.125em;}#mermaid-svg-wBnmI6gZaQXNXvH3 .node .label-icon path{fill:currentColor;stroke:revert;stroke-width:revert;}#mermaid-svg-wBnmI6gZaQXNXvH3 :root{--mermaid-font-family:"trebuchet ms",verdana,arial,sans-serif;} sync (非流式)
stream (流式)
用户输入文本

点击发送
send() 函数
buildBody()

按协议拼装请求体
模式?
sendSync()
sendStream()
fetch POST
resp.json()

获取完整响应
extractText()

提取正文
marked.parse()

渲染 Markdown
fetch POST

(stream:true)
resp.body.getReader()

读取流
逐帧解析 SSE 事件
extractDelta()

提取增量
累加到 full 变量
marked.parse()

实时渲染

9.3 协议选择与端点映射

javascript 复制代码
/* 三种协议对应的代理端点 (sync=非流式, stream=流式) */
const endpoints = {
  'openai-completions': {
    sync: '/api/openai-completions/chat',
    stream: '/api/openai-completions/chat/stream'
  },
  'openai-responses': {
    sync: '/api/openai-responses/chat',
    stream: '/api/openai-responses/chat/stream'
  },
  'anthropic-messages': {
    sync: '/api/anthropic-messages/chat',
    stream: '/api/anthropic-messages/chat/stream'
  },
};

9.4 请求体拼装------三种协议的核心差异

javascript 复制代码
/**
 * 请求体拼装 ------ 学习重点 1: 三种协议的请求格式差异
 *
 * 这个函数刻意不做封装,把三种协议的请求格式平铺展示,
 * 便于对照学习每种协议的格式差异。
 */
function buildBody(text, isStream) {
  const provider = document.getElementById('provider').value;
  const sysPrompt = "你是吉林省文旅推荐官";
  const usrPrompt = text || "介绍吉林省文旅资源";

  switch (provider) {
    /* ── 协议 1: OpenAI Chat Completions ─────────────────
     * 系统提示词 = messages 数组中 role=system 的消息
     * 非流式响应正文: choices[0].message.content
     * 流式增量: data.choices[0].delta.content, 以 data:[DONE] 结束
     * ───────────────────────────────────────────────────── */
    case 'openai-completions':
      return {
        model: "mimo-v2.5",
        messages: [
          { role: "system", content: sysPrompt },
          { role: "user", content: usrPrompt }
        ],
        max_tokens: 8192,
        stream: isStream
      };

    /* ── 协议 2: OpenAI Responses ────────────────────────
     * 系统提示词 = 顶层 instructions 字段 (不在 messages 里)
     * 输出上限参数名: max_output_tokens (不是 max_tokens)
     * 非流式响应正文: output[] 中 type=output_text 片段的 text
     * 流式增量: event:response.output_text.delta → data.delta
     * ───────────────────────────────────────────────────── */
    case 'openai-responses':
      return {
        model: "mimo-v2.5",
        instructions: sysPrompt,
        input: [{ role: "user", content: [{ type: "input_text", text: usrPrompt }] }],
        max_output_tokens: 8192,
        stream: isStream
      };

    /* ── 协议 3: Anthropic Messages ──────────────────────
     * 系统提示词 = 顶层 system 字段 (区别于 OpenAI 系协议)
     * max_tokens = 协议【必填】参数, 遗漏时上游返回 400
     * 认证方式也不同: x-api-key + anthropic-version (后端处理)
     * 非流式响应正文: content[] 中 type=text 块的 text
     * 流式事件序列: message_start → content_block_start
     *   → content_block_delta (data.delta.text) → message_stop
     * ───────────────────────────────────────────────────── */
    case 'anthropic-messages':
      return {
        model: "mimo-v2.5",
        max_tokens: 8192,
        system: sysPrompt,
        messages: [{ role: "user", content: usrPrompt }],
        stream: isStream
      };
  }
}

9.5 非流式响应解析------提取正文

javascript 复制代码
/**
 * 非流式响应解析 ------ 学习重点 2: 三种协议的响应结构差异
 * 后端 (RestClient) 原样透传上游完整 JSON, 前端按协议取正文
 */
function extractText(data) {
  const provider = document.getElementById('provider').value;

  /* OpenAI Completions 响应:
   * { "choices": [ { "message": { "role": "assistant", "content": "正文" } } ] } */
  if (data.choices)
    return data.choices[0]?.message?.content || '(空响应)';

  /* OpenAI Responses 响应: 模型可能在正文前输出推理过程, 需过滤
   * { "output": [ { "type": "output_text", "text": "正文" } ] } */
  if (data.output)
    return stripThinking(
      data.output.filter(o => o.type === 'output_text').map(o => o.text).join('')
        || '(空响应)',
      provider);

  /* Anthropic Messages 响应:
   * { "content": [ { "type": "text", "text": "正文" } ], "stop_reason": "end_turn" } */
  if (data.content)
    return stripThinking(
      data.content.filter(c => c.type === 'text').map(c => c.text).join('')
        || '(空响应)',
      provider);

  return '(未识别的响应格式)';
}

9.6 流式 SSE 事件处理------逐帧解析

javascript 复制代码
/**
 * 流式对话 (SSE) ------ 学习重点 3: Server-Sent Events 逐帧解析
 *
 * SSE 帧格式 (帧与帧之间用空行 \n\n 分隔):
 *   event: content_block_delta   ← 事件名行 (可选)
 *   data: {"delta":{"text":"吉林"}} ← 数据行
 *
 * 代理端 (WebClient) 把上游 SSE 帧原样中继, 保留事件名;
 * 前端用 ReadableStream 手动逐帧解析, 再按协议提取增量。
 */
async function sendStream(text, label) {
  // ... 省略创建消息气泡等代码 ...

  let full = '';
  const provider = document.getElementById('provider').value;

  try {
    const resp = await fetch(getEndpoint(), {
      method: 'POST',
      headers: { 'Content-Type': 'application/json' },
      body: JSON.stringify(buildBody(text, true))
    });

    if (!resp.ok) { /* 错误处理 ... */ }

    const reader = resp.body.getReader();
    const decoder = new TextDecoder();
    let buf = '';

    while (true) {
      const { done, value } = await reader.read();
      if (done) break;

      // 解码二进制数据为文本,追加到缓冲区
      buf += decoder.decode(value, { stream: true }).replace(/\r\n/g, '\n');

      let idx;
      // 按 \n\n 分割帧(SSE 标准:帧之间用空行分隔)
      while ((idx = buf.indexOf('\n\n')) >= 0) {
        const frame = buf.slice(0, idx);
        buf = buf.slice(idx + 2);

        let eventName = '', data = '';
        for (const line of frame.split('\n')) {
          if (line.startsWith('event:'))
            eventName = line.slice(6).trim();
          else if (line.startsWith('data:'))
            data += line.slice(5).trim();
        }

        if (!data || data === '[DONE]' || eventName === 'done') continue;

        const delta = extractDelta(eventName, data);
        if (delta) {
          full += delta;
          // 实时渲染到气泡
          bubble.innerHTML = marked.parse(full)
              + '<span class="stream-cursor"></span>';
        }
      }
    }
  } catch (e) { /* 异常处理 ... */ }
}

9.7 流式增量提取------三种协议的差异

javascript 复制代码
/**
 * 流式增量提取 ------ 学习重点 4: 三种协议的流式事件格式差异
 * 代理原样中继上游事件名, 前端按 (协议 + 事件名) 提取文本
 */
function extractDelta(eventName, dataJson) {
  let d;
  try {
    d = JSON.parse(dataJson);
  } catch (e) {
    return '';
  }

  const provider = document.getElementById('provider').value;

  switch (provider) {
    /* OpenAI Completions: 无 event 行, 只有 data 行
     * data = {"choices":[{"delta":{"content":"增量文本"}}]} */
    case 'openai-completions':
      return d.choices?.[0]?.delta?.content || '';

    /* OpenAI Responses: 带事件名
     * event: response.output_text.delta
     * data = {"delta":"增量文本"} */
    case 'openai-responses':
      return eventName === 'response.output_text.delta'
        ? (d.delta || '') : '';

    /* Anthropic Messages: 带事件名
     * event: content_block_delta
     * data = {"delta":{"type":"text_delta","text":"增量"}} */
    case 'anthropic-messages':
      return (eventName === 'content_block_delta'
          && d.delta?.type === 'text_delta')
        ? (d.delta.text || '') : '';
  }

  return '';
}

9.8 SSE 流式解析图解

#mermaid-svg-R0kBaAqBft7lJx6v{font-family:"trebuchet ms",verdana,arial,sans-serif;font-size:16px;fill:#333;}@keyframes edge-animation-frame{from{stroke-dashoffset:0;}}@keyframes dash{to{stroke-dashoffset:0;}}#mermaid-svg-R0kBaAqBft7lJx6v .edge-animation-slow{stroke-dasharray:9,5!important;stroke-dashoffset:900;animation:dash 50s linear infinite;stroke-linecap:round;}#mermaid-svg-R0kBaAqBft7lJx6v .edge-animation-fast{stroke-dasharray:9,5!important;stroke-dashoffset:900;animation:dash 20s linear infinite;stroke-linecap:round;}#mermaid-svg-R0kBaAqBft7lJx6v .error-icon{fill:#552222;}#mermaid-svg-R0kBaAqBft7lJx6v .error-text{fill:#552222;stroke:#552222;}#mermaid-svg-R0kBaAqBft7lJx6v .edge-thickness-normal{stroke-width:1px;}#mermaid-svg-R0kBaAqBft7lJx6v .edge-thickness-thick{stroke-width:3.5px;}#mermaid-svg-R0kBaAqBft7lJx6v .edge-pattern-solid{stroke-dasharray:0;}#mermaid-svg-R0kBaAqBft7lJx6v .edge-thickness-invisible{stroke-width:0;fill:none;}#mermaid-svg-R0kBaAqBft7lJx6v .edge-pattern-dashed{stroke-dasharray:3;}#mermaid-svg-R0kBaAqBft7lJx6v .edge-pattern-dotted{stroke-dasharray:2;}#mermaid-svg-R0kBaAqBft7lJx6v .marker{fill:#333333;stroke:#333333;}#mermaid-svg-R0kBaAqBft7lJx6v .marker.cross{stroke:#333333;}#mermaid-svg-R0kBaAqBft7lJx6v svg{font-family:"trebuchet ms",verdana,arial,sans-serif;font-size:16px;}#mermaid-svg-R0kBaAqBft7lJx6v p{margin:0;}#mermaid-svg-R0kBaAqBft7lJx6v .label{font-family:"trebuchet ms",verdana,arial,sans-serif;color:#333;}#mermaid-svg-R0kBaAqBft7lJx6v .cluster-label text{fill:#333;}#mermaid-svg-R0kBaAqBft7lJx6v .cluster-label span{color:#333;}#mermaid-svg-R0kBaAqBft7lJx6v .cluster-label span p{background-color:transparent;}#mermaid-svg-R0kBaAqBft7lJx6v .label text,#mermaid-svg-R0kBaAqBft7lJx6v span{fill:#333;color:#333;}#mermaid-svg-R0kBaAqBft7lJx6v .node rect,#mermaid-svg-R0kBaAqBft7lJx6v .node circle,#mermaid-svg-R0kBaAqBft7lJx6v .node ellipse,#mermaid-svg-R0kBaAqBft7lJx6v .node polygon,#mermaid-svg-R0kBaAqBft7lJx6v .node path{fill:#ECECFF;stroke:#9370DB;stroke-width:1px;}#mermaid-svg-R0kBaAqBft7lJx6v .rough-node .label text,#mermaid-svg-R0kBaAqBft7lJx6v .node .label text,#mermaid-svg-R0kBaAqBft7lJx6v .image-shape .label,#mermaid-svg-R0kBaAqBft7lJx6v .icon-shape .label{text-anchor:middle;}#mermaid-svg-R0kBaAqBft7lJx6v .node .katex path{fill:#000;stroke:#000;stroke-width:1px;}#mermaid-svg-R0kBaAqBft7lJx6v .rough-node .label,#mermaid-svg-R0kBaAqBft7lJx6v .node .label,#mermaid-svg-R0kBaAqBft7lJx6v .image-shape .label,#mermaid-svg-R0kBaAqBft7lJx6v .icon-shape .label{text-align:center;}#mermaid-svg-R0kBaAqBft7lJx6v .node.clickable{cursor:pointer;}#mermaid-svg-R0kBaAqBft7lJx6v .root .anchor path{fill:#333333!important;stroke-width:0;stroke:#333333;}#mermaid-svg-R0kBaAqBft7lJx6v .arrowheadPath{fill:#333333;}#mermaid-svg-R0kBaAqBft7lJx6v .edgePath .path{stroke:#333333;stroke-width:2.0px;}#mermaid-svg-R0kBaAqBft7lJx6v .flowchart-link{stroke:#333333;fill:none;}#mermaid-svg-R0kBaAqBft7lJx6v .edgeLabel{background-color:rgba(232,232,232, 0.8);text-align:center;}#mermaid-svg-R0kBaAqBft7lJx6v .edgeLabel p{background-color:rgba(232,232,232, 0.8);}#mermaid-svg-R0kBaAqBft7lJx6v .edgeLabel rect{opacity:0.5;background-color:rgba(232,232,232, 0.8);fill:rgba(232,232,232, 0.8);}#mermaid-svg-R0kBaAqBft7lJx6v .labelBkg{background-color:rgba(232, 232, 232, 0.5);}#mermaid-svg-R0kBaAqBft7lJx6v .cluster rect{fill:#ffffde;stroke:#aaaa33;stroke-width:1px;}#mermaid-svg-R0kBaAqBft7lJx6v .cluster text{fill:#333;}#mermaid-svg-R0kBaAqBft7lJx6v .cluster span{color:#333;}#mermaid-svg-R0kBaAqBft7lJx6v div.mermaidTooltip{position:absolute;text-align:center;max-width:200px;padding:2px;font-family:"trebuchet ms",verdana,arial,sans-serif;font-size:12px;background:hsl(80, 100%, 96.2745098039%);border:1px solid #aaaa33;border-radius:2px;pointer-events:none;z-index:100;}#mermaid-svg-R0kBaAqBft7lJx6v .flowchartTitleText{text-anchor:middle;font-size:18px;fill:#333;}#mermaid-svg-R0kBaAqBft7lJx6v rect.text{fill:none;stroke-width:0;}#mermaid-svg-R0kBaAqBft7lJx6v .icon-shape,#mermaid-svg-R0kBaAqBft7lJx6v .image-shape{background-color:rgba(232,232,232, 0.8);text-align:center;}#mermaid-svg-R0kBaAqBft7lJx6v .icon-shape p,#mermaid-svg-R0kBaAqBft7lJx6v .image-shape p{background-color:rgba(232,232,232, 0.8);padding:2px;}#mermaid-svg-R0kBaAqBft7lJx6v .icon-shape .label rect,#mermaid-svg-R0kBaAqBft7lJx6v .image-shape .label rect{opacity:0.5;background-color:rgba(232,232,232, 0.8);fill:rgba(232,232,232, 0.8);}#mermaid-svg-R0kBaAqBft7lJx6v .label-icon{display:inline-block;height:1em;overflow:visible;vertical-align:-0.125em;}#mermaid-svg-R0kBaAqBft7lJx6v .node .label-icon path{fill:currentColor;stroke:revert;stroke-width:revert;}#mermaid-svg-R0kBaAqBft7lJx6v :root{--mermaid-font-family:"trebuchet ms",verdana,arial,sans-serif;} ReadableStream

读取到二进制数据
缓冲区 buf

decode 为文本追加
帧解析器

查找 \

\

分隔符
按行解析

event:/data:
extractDelta()

按协议提取增量文本
full += delta

实时渲染到气泡

9.9 CSS 主题与动画亮点

本项目的 CSS 采用吉林文旅青玉色系,主要设计元素包括:

元素 设计描述
页面底色 宣纸暖白 #f5f3ec,带 SVG 噪声颗粒质感
主色调 青玉 #2e7d68(呼应长白山/松花江意象)
头部 渐变深玉色 + SVG 山峦剪影 + 日晕光圈 + "吉"字印章
用户气泡 青玉渐变 + 微光呼吸动画
助手气泡 白底 + 左侧翡翠装饰线 + 流式边框光效
输入框 聚焦时渐变光带 + 多层光晕脉冲
流式光标 2px 宽闪烁竖线(stream-cursor

衔接下一章:项目代码全部讲解完毕,下一章将介绍如何测试和验证整个系统。


第十章 测试与验证

本章定位 :代码写完了,如何验证它能正常工作?本章将提供所有 6 个端点的完整测试参数,包括 curl 命令、Apifox/Postman 录入指南,以及前端页面操作步骤。

10.1 测试工具选择

推荐使用以下任一 API 测试工具:

  • Apifox(可视化界面,推荐新手)
  • Postman(经典 API 测试工具)
  • curl 命令行(最快,适合开发者)
  • 项目自带的前端页面http://localhost:8080

10.2 使用 curl 测试非流式端点

测试 1:OpenAI Completions 非流式
bash 复制代码
curl -X POST http://localhost:8080/api/openai-completions/chat \
  -H "Content-Type: application/json" \
  -d '{
    "model": "mimo-v2.5",
    "messages": [
      {"role": "system", "content": "你是吉林省文旅推荐官"},
      {"role": "user", "content": "用三句话介绍吉林雾凇"}
    ],
    "max_tokens": 8192,
    "stream": false
  }'

预期成功响应(200 OK):

json 复制代码
{
  "id": "chatcmpl-xxx",
  "object": "chat.completion",
  "choices": [
    {
      "index": 0,
      "message": {
        "role": "assistant",
        "content": "吉林雾凇是..."  // 模型生成的完整回复
      },
      "finish_reason": "stop"
    }
  ],
  "usage": {
    "prompt_tokens": 20,
    "completion_tokens": 150,
    "total_tokens": 170
  }
}

提取正文路径choices[0].message.content

测试 2:OpenAI Responses 非流式
bash 复制代码
curl -X POST http://localhost:8080/api/openai-responses/chat \
  -H "Content-Type: application/json" \
  -d '{
    "model": "mimo-v2.5",
    "instructions": "你是吉林省文旅推荐官",
    "input": [
      {
        "role": "user",
        "content": [{"type": "input_text", "text": "用三句话介绍吉林雾凇"}]
      }
    ],
    "max_output_tokens": 8192,
    "stream": false
  }'

预期成功响应(200 OK):

json 复制代码
{
  "id": "resp-xxx",
  "object": "response",
  "output": [
    {
      "type": "output_text",
      "text": "吉林雾凇是..."  // 模型生成的完整回复
    }
  ],
  "usage": {
    "input_tokens": 20,
    "output_tokens": 150
  }
}

提取正文路径output[]output_text 片段的 text 字段(兼容嵌套在 message.content[] 内的情况)

测试 3:Anthropic Messages 非流式
bash 复制代码
curl -X POST http://localhost:8080/api/anthropic-messages/chat \
  -H "Content-Type: application/json" \
  -d '{
    "model": "mimo-v2.5",
    "max_tokens": 8192,
    "system": "你是吉林省文旅推荐官",
    "messages": [{"role": "user", "content": "用三句话介绍吉林雾凇"}]
  }'

预期成功响应(200 OK):

json 复制代码
{
  "id": "msg-xxx",
  "type": "message",
  "content": [
    {
      "type": "text",
      "text": "吉林雾凇是..."  // 模型生成的完整回复
    }
  ],
  "stop_reason": "end_turn",
  "usage": {
    "input_tokens": 20,
    "output_tokens": 150
  }
}

提取正文路径content[]type=texttext 字段

10.3 使用 curl 测试流式端点

测试 4:OpenAI Completions 流式
bash 复制代码
curl -N -X POST http://localhost:8080/api/openai-completions/chat/stream \
  -H "Content-Type: application/json" \
  -d '{
    "model": "mimo-v2.5",
    "messages": [
      {"role": "system", "content": "你是吉林省文旅推荐官"},
      {"role": "user", "content": "用三句话介绍吉林雾凇"}
    ],
    "max_tokens": 8192,
    "stream": true
  }'

-N 参数告诉 curl 不缓冲输出,实时显示 SSE 事件。

预期流式响应

复制代码
data: {"choices":[{"delta":{"content":""},"index":0}]}

data: {"choices":[{"delta":{"content":"吉林"},"index":0}]}

data: {"choices":[{"delta":{"content":"雾凇"},"index":0}]}

data: {"choices":[{"delta":{"content":"是"},"index":0}]}

data: [DONE]

增量提取choices[0].delta.content

测试 5:OpenAI Responses 流式
bash 复制代码
curl -N -X POST http://localhost:8080/api/openai-responses/chat/stream \
  -H "Content-Type: application/json" \
  -d '{
    "model": "mimo-v2.5",
    "instructions": "你是吉林省文旅推荐官",
    "input": [
      {
        "role": "user",
        "content": [{"type": "input_text", "text": "用三句话介绍吉林雾凇"}]
      }
    ],
    "max_output_tokens": 8192,
    "stream": true
  }'

预期流式响应

复制代码
event: response.created
data: {"type":"response.created","response":{...}}

event: response.output_text.delta
data: {"type":"response.output_text.delta","delta":"吉林"}

event: response.output_text.delta
data: {"type":"response.output_text.delta","delta":"雾凇"}

event: response.completed
data: {"type":"response.completed","response":{...}}

增量提取 :仅取 event: response.output_text.deltadata.delta

测试 6:Anthropic Messages 流式
bash 复制代码
curl -N -X POST http://localhost:8080/api/anthropic-messages/chat/stream \
  -H "Content-Type: application/json" \
  -d '{
    "model": "mimo-v2.5",
    "max_tokens": 8192,
    "system": "你是吉林省文旅推荐官",
    "messages": [{"role": "user", "content": "用三句话介绍吉林雾凇"}],
    "stream": true
  }'

预期流式响应

复制代码
event: message_start
data: {"type":"message_start","message":{...}}

event: content_block_start
data: {"type":"content_block_start","index":0,...}

event: content_block_delta
data: {"type":"content_block_delta","delta":{"type":"text_delta","text":"吉林"}}

event: content_block_delta
data: {"type":"content_block_delta","delta":{"type":"text_delta","text":"雾凇"}}

event: message_stop
data: {"type":"message_stop"}

增量提取 :仅取 event: content_block_deltadelta.type=text_deltadelta.text

10.4 使用 Apifox / Postman 录入指南

通用操作步骤
  1. 新建请求 → 选择 POST 方法
  2. 输入 URL:http://localhost:8080/api/<协议路径>/chat(或 /chat/stream
  3. 在 Headers 中添加:Content-Type: application/json
  4. 在 Body 中选择 rawJSON
  5. 粘贴对应协议的 JSON 请求体
各协议 Body 录入示例

OpenAI Completions(非流式)

json 复制代码
{
  "model": "mimo-v2.5",
  "messages": [
    {"role": "system", "content": "你是吉林省文旅推荐官"},
    {"role": "user", "content": "你好"}
  ],
  "max_tokens": 8192,
  "stream": false
}

OpenAI Responses(非流式)

json 复制代码
{
  "model": "mimo-v2.5",
  "instructions": "你是吉林省文旅推荐官",
  "input": [
    {
      "role": "user",
      "content": [{"type": "input_text", "text": "你好"}]
    }
  ],
  "max_output_tokens": 8192,
  "stream": false
}

Anthropic Messages(非流式)

json 复制代码
{
  "model": "mimo-v2.5",
  "max_tokens": 8192,
  "system": "你是吉林省文旅推荐官",
  "messages": [{"role": "user", "content": "你好"}]
}

提示 :将上面三个 JSON 中的 "stream": false 改为 "stream": true(或将 URL 改为 /chat/stream),即可测试流式端点。

Apifox 环境变量设置建议

在 Apifox 中可以设置环境变量,方便复用:

变量名 用途
base_url http://localhost:8080 代理服务地址
model mimo-v2.5 模型名称

在请求 Body 中使用 {``{base_url}}{``{model}} 引用变量。

10.5 多轮对话测试参数

除了单轮对话,你还可以测试多轮对话场景:

OpenAI Completions 多轮对话
json 复制代码
{
  "model": "mimo-v2.5",
  "messages": [
    {"role": "system", "content": "你是吉林省文旅推荐官"},
    {"role": "user", "content": "吉林有什么好玩的?"},
    {"role": "assistant", "content": "吉林省有很多旅游景点..."},
    {"role": "user", "content": "详细介绍一下长白山"}
  ],
  "max_tokens": 2048,
  "stream": false
}
OpenAI Responses 多轮对话
json 复制代码
{
  "model": "mimo-v2.5",
  "instructions": "你是吉林省文旅推荐官",
  "input": [
    {"role": "user", "content": [{"type": "input_text", "text": "吉林有什么好玩的?"}]},
    {"role": "assistant", "content": [{"type": "output_text", "text": "吉林省有很多旅游景点..."}]},
    {"role": "user", "content": [{"type": "input_text", "text": "详细介绍一下长白山"}]}
  ],
  "max_output_tokens": 2048,
  "stream": false
}
Anthropic Messages 多轮对话
json 复制代码
{
  "model": "mimo-v2.5",
  "max_tokens": 2048,
  "system": "你是吉林省文旅推荐官",
  "messages": [
    {"role": "user", "content": "吉林有什么好玩的?"},
    {"role": "assistant", "content": "吉林省有很多旅游景点..."},
    {"role": "user", "content": "详细介绍一下长白山"}
  ]
}

10.6 错误场景测试参数

测试缺少必填参数(Anthropic 协议)

故意省略 max_tokens,验证代理兜底机制:

bash 复制代码
curl -X POST http://localhost:8080/api/anthropic-messages/chat \
  -H "Content-Type: application/json" \
  -d '{
    "model": "mimo-v2.5",
    "system": "你是吉林省文旅推荐官",
    "messages": [{"role": "user", "content": "你好"}]
  }'

预期行为 :代理服务自动填充 max_tokens: 8192(来自 application.yaml 配置),请求正常完成。

测试错误的模型名
bash 复制代码
curl -X POST http://localhost:8080/api/openai-completions/chat \
  -H "Content-Type: application/json" \
  -d '{
    "model": "nonexistent-model-xyz",
    "messages": [
      {"role": "user", "content": "你好"}
    ],
    "max_tokens": 100
  }'

预期错误响应(502 或 400):

json 复制代码
{
  "error": "上游 400 Bad Request: ...",
  "provider": "openai-completions",
  "status": 400
}

10.7 前端页面测试

启动项目后,浏览器访问 http://localhost:8080,即可看到吉林文旅主题的对话界面。

操作步骤
  1. 选择协议 :在顶部"协议"下拉框中选择 OpenAI Completions / OpenAI Responses / Anthropic Messages
  2. 选择模式:点击"非流式 RestClient"或"流式 WebClient"按钮
  3. 观察端点提示:右侧会显示当前的端点 URL 和客户端类型
  4. 输入问题:在底部输入框输入问题(如"介绍吉林省文旅资源")
  5. 发送:点击"发送"按钮或按 Enter 键
  6. 观察响应
    • 非流式:等待完整响应后一次性显示
    • 流式:逐字显示,带闪烁光标效果
  7. 清空对话:点击"清空"按钮清除所有消息
页面上的调试技巧
  1. 打开浏览器开发者工具(F12)→ Network 面板
  2. 找到 /api/... 请求,查看:
    • Request Payload:确认请求体格式是否正确
    • Response:查看上游返回的原始 JSON
    • EventStream(流式时):查看 SSE 事件序列

10.8 常见问题排查

现象 原因 解决方案
400 Bad Request Anthropic 协议未传 max_tokens 请求体加上 "max_tokens": 8192
401 Unauthorized API Key 错误或过期 检查 application.yaml 中的 api-key
429 Too Many Requests 上游限流 等待 retry_after 秒后重试
502 Bad Gateway 上游服务不可用 检查 base-url 是否可达
流式响应无内容 请求体未设 "stream": true 确保 "stream": true
空响应 模型名错误 检查 model 字段值是否被上游支持
Anthropic 流式无增量 只有 thinking_deltatext_delta 正常现象,模型在推理阶段不输出文本

附录 A:三种协议请求体格式速查卡

yaml 复制代码
# ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
# OpenAI Chat Completions
# ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
model: "mimo-v2.5"
messages:
  - { role: "system", content: "你是吉林省文旅推荐官" }
  - { role: "user", content: "介绍吉林省文旅资源" }
max_tokens: 8192    # 可选
stream: false
# 认证: Authorization: Bearer <key>

# ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
# OpenAI Responses
# ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
model: "mimo-v2.5"
instructions: "你是吉林省文旅推荐官"
input:
  - role: "user"
    content:
      - { type: "input_text", text: "介绍吉林省文旅资源" }
max_output_tokens: 8192    # 可选
stream: false
# 认证: Authorization: Bearer <key>

# ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
# Anthropic Messages
# ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
model: "mimo-v2.5"
max_tokens: 8192    # 必填!
system: "你是吉林省文旅推荐官"
messages:
  - { role: "user", content: "介绍吉林省文旅资源" }
stream: false
# 认证: x-api-key: <key> + anthropic-version: 2023-06-01

附录 B:项目启动命令

bash 复制代码
# 进入项目目录
cd rest

# 编译并启动(确保已安装 JDK 25+ 和 Maven)
mvn spring-boot:run

# 启动成功后访问:
# 前端页面: http://localhost:8080
# API 端点: http://localhost:8080/api/openai-completions/chat

教程完成。通过十个章节的零跳跃学习,你已从"什么是大模型"走到了"能独立搭建一个 LLM API 协议代理服务"。三种协议的格式差异、非流式/流式的实现方式、异常处理的完整链路,都在代码注释和图表中得到了充分阐释。第十章提供了 6 个端点的完整测试参数,可直接复制到 curl、Apifox 或 Postman 中使用。

相关推荐
会飞的胖达喵1 小时前
MiniMax-Music3 本地全量模型部署与 API 生成实战
大模型·agent·音乐模型·minimax-music3
VIP_CQCRE2 小时前
把 OpenCode 接入 Ace Data Cloud:让 AI 编程能力真正进入 IDE 工作流
大模型·ai编程·开发工具·opencode·acedatacloud
脉动数据行情12 小时前
Java SpringBoot 国际期货批量采集实践 美原油 / 黄金 / 指数期货定时落库
java·开发语言·spring boot
DogDaoDao3 小时前
MotionWAM 深度解析:让视频世界模型跑进实时人形机器人全身控制
深度学习·机器人·大模型·音视频·人形机器人·视频大模型·motionwam
Zzzzmo_3 小时前
SpringBoot 日志
java·spring boot·spring
计算机毕设定制辅导-无忧学长3 小时前
《基于SpringBoot的家政服务管理平台的设计与实现》
java·vue.js·spring boot·计算机毕业设计选题推荐·家政服务管理平台
xiaoqiMikko4 小时前
pom 里没有、代码里没调过,但 WebClient 默认用的就是 netty-resolver-dns
java·spring boot
蓝速科技4 小时前
涉外酒店前台双屏翻译机落地应用指南
运维·人工智能·科技·语言模型·自然语言处理·语音识别
TAN-90°-4 小时前
Deep Learning for Computer Vision——Large Scale Distributed Training
人工智能·深度学习·神经网络·算法·机器学习·计算机视觉·语言模型