Appearance
一条 AI 消息是怎么产生的:追踪完整模型调用链
上一篇我们从整体上认识了 yudao-module-ai,知道了模型、聊天、知识库、Tool、MCP、Workflow 等能力分别处在什么位置,也看过了 ai_model、ai_chat_conversation、ai_chat_message 等核心表。
接下来,我们来看用户发送一条消息到 AI 消息响应到前端这个过程内部到底发生了什么?
text
AI 聊天页面
frontend/src/views/ai/chat/index/index.vue
↓
ChatMessageApi.sendChatMessageStream(...)
frontend/src/api/ai/chat/message/index.ts
↓
POST /admin-api/ai/chat/message/send-stream
↓
AiChatMessageController
↓
AiChatMessageServiceImpl
│
├── 查 Conversation
├── 查历史消息
├── 找 Model
├── 创建 ChatModel
├── RAG / 联网搜索
├── 保存 User Message
├── 创建 Assistant Message
├── 构造 Prompt
│
▼
StreamingChatModel.stream(prompt)
↓
Spring AI
↓
真实模型平台
↓
Flux<ChatResponse>
↓
SSE
↓
Vue 页面逐段接收
↓
完整 Assistant 内容落库从浏览器里的 send-stream 开始
前端最终发出了什么请求
我们先从前端开始分析,
聊天主页面位于:
text
frontend/src/views/ai/chat/index/index.vue用户输入内容以后,最终会进入:
ts
const doSendMessage = async (content: string) => {
if (content.length < 1) {
message.error('发送失败,原因:内容为空!')
return
}
if (activeConversationId.value == null) {
message.error('还没创建对话,不能发送!')
return
}
await doSendMessageStream({
conversationId: activeConversationId.value,
content,
attachmentUrls: [...uploadFiles.value]
} as ChatMessageVO)
}真正负责发送流式请求的是:
ts
doSendMessageStream(...)在正式请求后端之前,前端还会先向当前消息列表插入两条临时消息:
ts
activeMessageList.value.push({
id: -1,
type: 'user',
content: userMessage.content
})
activeMessageList.value.push({
id: -2,
type: 'assistant',
content: '思考中...'
})也就是说,当我们点击“发送”以后,页面不需要等待服务器返回第一段数据,首先就可以立即展示:
text
我的问题
思考中...等真正的流式响应回来以后,这两条临时数据才会被后端返回的真实 Message 替换。
继续往下看。
真正的 HTTP 请求封装位于:
text
frontend/src/api/ai/chat/message/index.ts核心代码是:
ts
sendChatMessageStream: async (
conversationId,
content,
ctrl,
enableContext,
enableWebSearch,
onMessage,
onError,
onClose,
attachmentUrls
) => {
const token = getAccessToken()
return fetchEventSource(
`${config.base_url}/ai/chat/message/send-stream`,
{
method: 'post',
headers: {
'Content-Type': 'application/json',
Authorization: `Bearer ${token}`
},
body: JSON.stringify({
conversationId,
content,
useContext: enableContext,
useSearch: enableWebSearch,
attachmentUrls: attachmentUrls || []
}),
onmessage: onMessage,
onerror: onError,
onclose: onClose,
signal: ctrl.signal
}
)
}所以发送一条消息时,真正提交给后端的内容是:
json
{
"conversationId": 1,
"content": "你好",
"useContext": true,
"useSearch": false,
"attachmentUrls": []
}对应后端的 Request VO 也完全一致:
text
backend/yudao-module-ai/src/main/java/
cn/iocoder/yudao/module/ai/controller/admin/chat/vo/message/
AiChatMessageSendReqVO.javajava
public class AiChatMessageSendReqVO {
private Long conversationId;
private String content;
private Boolean useContext;
private Boolean useSearch;
private List<String> attachmentUrls;
}因此到这里只是一次普通的HTTP 请求,请求参数包括:
text
聊天对话 ID
+
用户输入
+
上下文开关
+
联网搜索开关
+
附件为什么这里使用 SSE
和我们常见的普通后台接口不同,聊天页面没有等待模型把整段答案生成完成以后,再一次性显示。
而是:
text
模型生成一点
↓
后端返回一点
↓
浏览器显示一点所以前端没有继续使用普通 Axios 请求,而是使用:
ts
fetchEventSource(...)源码中直接写了注释:
ts
// 为什么不用 axios 呢?因为它不支持 SSE 调用SSE,也就是 Server-Sent Events,可以让服务器在一个持续建立的 HTTP 连接上不断向浏览器发送事件。
SSE 的底层原理具体可以参考我们后续原理栏目的内容。
上一节我们在浏览器 Network 中看到的:
text
/admin-api/ai/chat/message/send-stream就是这条流式请求。
请求进入后端
找到 AiChatMessageController
根据请求路径:
text
/ai/chat/message/send-stream可以直接找到:
text
backend/yudao-module-ai/src/main/java/
cn/iocoder/yudao/module/ai/controller/admin/chat/
AiChatMessageController.java流式接口则是:
java
@PostMapping(
value = "/send-stream",
produces = MediaType.TEXT_EVENT_STREAM_VALUE
)
public Flux<CommonResult<AiChatMessageSendRespVO>>
sendChatMessageStream(
@Valid @RequestBody AiChatMessageSendReqVO sendReqVO) {
return chatMessageService.sendChatMessageStream(
sendReqVO,
getLoginUserId()
);
}注意这两个地方。
第一个是:
java
produces = MediaType.TEXT_EVENT_STREAM_VALUE它告诉 Spring:
这个接口返回的不是普通 JSON,而是 SSE 流。
第二个是返回类型:
java
Flux<CommonResult<AiChatMessageSendRespVO>>Flux 不是一个结果,而是一串会持续产生的数据。
所以:
text
前端 fetchEventSource和:
text
后端 Flux + TEXT_EVENT_STREAM正好形成了流式通信的两端。
send-stream 把请求交给了谁
Controller 本身几乎没有业务逻辑。
它只是取出当前登录用户:
java
getLoginUserId()然后调用:
java
chatMessageService.sendChatMessageStream(
sendReqVO,
getLoginUserId()
);所以继续往下看,真正重要的文件是:
text
backend/yudao-module-ai/src/main/java/
cn/iocoder/yudao/module/ai/service/chat/
AiChatMessageServiceImpl.java这也是整个聊天调用链里最核心的一个类。
进入聊天核心逻辑
校验 Conversation
进入:
java
sendChatMessageStream(...)第一件事情还不是找模型,而是先找当前聊天对话:
java
AiChatConversationDO conversation =
chatConversationService
.validateChatConversationExists(
sendReqVO.getConversationId()
);
if (ObjUtil.notEqual(conversation.getUserId(), userId)) {
throw exception(CHAT_CONVERSATION_NOT_EXISTS);
}为什么一定要先找 Conversation?
因为我们上一篇已经看到,一次对话本身保存了:
text
modelId
model
systemMessage
temperature
maxTokens
maxContexts所以前端发送消息的时候,并没有每次都重新传:
json
{
"model": "deepseek-chat",
"temperature": 0.7,
"maxTokens": 2048
}前端只传:
text
conversationId后端再根据 Conversation 找到这次聊天真正应该使用的模型和配置。
因此:
text
Conversation就是一组连续聊天的运行上下文。
找到这次使用的 Model
接着代码会读取:
java
AiModelDO model =
modalService.validateModel(
conversation.getModelId()
);找到具体模型记录。
因此链路现在已经变成:
text
conversationId
↓
ai_chat_conversation
↓
modelId
↓
ai_model读取历史消息
紧接着还有:
java
List<AiChatMessageDO> historyMessages =
chatMessageMapper
.selectListByConversationId(
conversation.getId()
);这里拿到的是当前 Conversation 之前已经存在的消息。
但是查出来所有历史消息,不代表所有历史消息都会发给模型。
真正选择多少历史消息,是后面的 filterContextMessages(...)决定的。
当前实现会根据:sendReqVO.useContext 和conversation.maxContexts
如果没有开启上下文:
java
if (conversation.getMaxContexts() == null
|| ObjUtil.notEqual(
sendReqVO.getUseContext(),
Boolean.TRUE)) {
return Collections.emptyList();
}就完全不带历史消息。
如果开启,则会从最近的消息开始,按照:
text
User + Assistant为一组向前取。
核心判断类似:
java
if (contextMessages.size()
>= conversation.getMaxContexts() * 2) {
break;
}所以:
text
maxContexts = 10表示最多取最近 10 组问答,而不是无限把整张消息表都塞进 Prompt。
模型实例是怎么创建出来的
从 modelId 找到模型配置
刚才只是拿到了:
java
AiModelDO model但数据库里的一条记录显然还不能直接调用大模型。
真正创建模型客户端的代码在:
text
backend/yudao-module-ai/src/main/java/
cn/iocoder/yudao/module/ai/service/model/
AiModelServiceImpl.javaChat 调用时执行:
java
StreamingChatModel chatModel =
modalService.getChatModel(model.getId());对应的实现是:
java
@Override
public ChatModel getChatModel(Long id) {
AiModelDO model = validateModel(id);
AiApiKeyDO apiKey =
apiKeyService.validateApiKey(
model.getKeyId()
);
AiPlatformEnum platform =
AiPlatformEnum.validatePlatform(
apiKey.getPlatform()
);
return modelFactory.getOrCreateChatModel(
platform,
apiKey.getApiKey(),
apiKey.getUrl()
);
}这里终于把上一篇讲过的两张表连起来了:
text
ai_model
│
│ keyId
▼
ai_api_key系统从 Model 中找到:
text
keyId然后再从 API Key 中获取:
text
platform
apiKey
url从 keyId 找到 API Key
这也解释了为什么前面配置模型时,要先创建 API Key,再创建 Model。
真正调用模型需要两类信息:
text
AiApiKeyDO
→ 我怎么访问模型平台
AiModelDO
→ 我要使用哪个具体模型以及什么参数AiModelService 怎么拿到 ChatModel
继续看:
text
backend/yudao-module-ai/src/main/java/
cn/iocoder/yudao/module/ai/framework/ai/core/model/
AiModelFactoryImpl.java核心方法:
java
public ChatModel getOrCreateChatModel(
AiPlatformEnum platform,
String rawApiKey,
String rawUrl) {
switch (platform) {
case TONG_YI:
return buildTongYiChatModel(apiKey);
case DEEP_SEEK:
return buildDeepSeekChatModel(apiKey);
case OPENAI:
return buildOpenAiChatModel(apiKey, url);
case ANTHROPIC:
return buildAnthropicChatModel(apiKey, url);
case OLLAMA:
return buildOllamaChatModel(url);
// ...
}
}实际代码支持的平台远不止我这里简写的几个。
当前平台枚举位于:
text
backend/yudao-module-ai/src/main/java/
cn/iocoder/yudao/module/ai/enums/model/
AiPlatformEnum.java里面可以看到:
java
TONG_YI("TongYi", "通义千问"),
DEEP_SEEK("DeepSeek", "DeepSeek"),
DOU_BAO("DouBao", "豆包"),
SILICON_FLOW("SiliconFlow", "硅基流动"),
OPENAI("OpenAI", "OpenAI"),
ANTHROPIC("Anthropic", "Anthropic"),
GEMINI("Gemini", "Gemini"),
OLLAMA("Ollama", "Ollama"),
// ...不同模型平台怎么被统一起来
这一步其实是 Spring AI 在当前项目中非常重要的价值。
上层聊天代码并没有写成:
java
if (DeepSeek) {
调 DeepSeek SDK
} else if (OpenAI) {
调 OpenAI SDK
} else if (Anthropic) {
调 Anthropic SDK
}而是最终统一拿到:
java
ChatModel或者流式场景中的:
java
StreamingChatModel所以 Chat Service 后面可以完全不关心:
text
DeepSeek
OpenAI
通义
Anthropic
Ollama具体是哪一家。
它只需要:
java
chatModel.stream(prompt);当前 AI 模块固定使用的 Spring AI 版本为:
xml
<spring-ai.version>1.1.8</spring-ai.version>同时使用 Spring AI Alibaba 1.1.2.2。
这就是项目把不同模型 Provider 统一到一条 Chat 调用链上的基础。
Prompt 是怎么组装出来的
现在已经有了:
text
Conversation
Model
ChatModel
历史消息
用户本次输入下一步就是真正构造发给大模型的 Prompt。
仍然在:
text
backend/yudao-module-ai/src/main/java/
cn/iocoder/yudao/module/ai/service/chat/
AiChatMessageServiceImpl.java方法是:
java
buildPrompt(...)System Message
第一部分是 Conversation 自己保存的角色设定:
java
if (StrUtil.isNotBlank(
conversation.getSystemMessage())) {
chatMessages.add(
new SystemMessage(
conversation.getSystemMessage()
)
);
}所以如果聊天设置中写了:
text
你是一名 Java 架构师……最终就是作为:
text
SystemMessage进入 Prompt。
历史上下文
然后加入刚才筛选出来的历史消息:
java
List<AiChatMessageDO> contextMessages =
filterContextMessages(
messages,
conversation,
sendReqVO
);
contextMessages.forEach(message -> {
chatMessages.add(
AiUtils.buildMessage(
message.getType(),
message.getContent()
)
);
});AiUtils.buildMessage() 再把数据库里的:
text
user
assistant
system转换为 Spring AI 的:
text
UserMessage
AssistantMessage
SystemMessage实现位于:
text
backend/yudao-module-ai/src/main/java/
cn/iocoder/yudao/module/ai/util/
AiUtils.javajava
public static Message buildMessage(
String type,
String content) {
if (MessageType.USER.getValue().equals(type)) {
return new UserMessage(content);
}
if (MessageType.ASSISTANT.getValue().equals(type)) {
return new AssistantMessage(content);
}
if (MessageType.SYSTEM.getValue().equals(type)) {
return new SystemMessage(content);
}
// ...
}当前用户消息
历史消息之后,才加入用户本轮真正输入的内容:
java
chatMessages.add(
new UserMessage(
sendReqVO.getContent()
)
);到这里,一个最普通的聊天 Prompt 已经大概是:
text
System
你是一名 Java 架构师
User
之前的问题
Assistant
之前的回答
User
这一次的新问题但是当前项目还会继续往里加其他内容。
知识库召回
在构建 Prompt 之前,Service 已经调用:
java
List<AiKnowledgeSegmentSearchRespBO>
knowledgeSegments =
recallKnowledgeSegment(
sendReqVO.getContent(),
conversation
);不过并不是每一次聊天都会执行 RAG。
recallKnowledgeSegment() 首先检查当前 Conversation 有没有角色:
java
if (conversation == null
|| conversation.getRoleId() == null) {
return Collections.emptyList();
}然后继续检查角色是否配置:
text
knowledgeIds没有知识库,同样直接返回空。
真正有知识库以后,才会遍历知识库并调用:
java
knowledgeSegmentService.searchKnowledgeSegment(...)进行召回。
召回结果在构造 Prompt 时会被包装成类似:
text
<Reference>
知识片段内容
</Reference>再作为额外的:
java
UserMessage加入 Prompt。
实际逻辑:
java
if (CollUtil.isNotEmpty(knowledgeSegments)) {
String reference = knowledgeSegments.stream()
.map(segment ->
"<Reference>"
+ segment.getContent()
+ "</Reference>")
.collect(Collectors.joining("\n\n"));
chatMessages.add(
new UserMessage(
String.format(
KNOWLEDGE_USER_MESSAGE_TEMPLATE,
reference
)
)
);
}因此当前 RAG 本质上也是:
text
先检索
↓
拿到知识片段
↓
拼进 Prompt
↓
让模型基于这些内容回答联网搜索与附件
如果前端开启:
text
联网搜索并且当前系统已经配置 AiWebSearchClient,Service 会调用:
java
webSearchClient.search(...)然后把搜索结果包装为:
text
<WebSearch>
...
</WebSearch>继续作为 UserMessage 塞进 Prompt。
附件也是类似处理。
如果:
java
sendReqVO.getAttachmentUrls()不为空,会调用:
java
buildAttachmentUserMessage(...)文本文件会读取内容,图片会下载并转成 Base64,最后包装成:
text
<Attachment name="...">
...
</Attachment>再作为新的 UserMessage。
所以当前 Prompt 已经可能非常复杂:
text
System Message
↓
历史消息
↓
当前用户消息
↓
RAG Reference
↓
Web Search
↓
AttachmentTool 和 MCP
Prompt 的消息部分构建完以后,还没有结束。
代码继续调用:
java
List<ToolCallback> toolCallbacks =
getToolCallbackListByRoleId(
conversation.getRoleId()
);如果当前 Chat Role 配置了:
text
toolIds系统会根据数据库中的 Tool 名称:
java
toolCallbackResolver.resolve(toolName)找到真正的 ToolCallback。
如果角色配置了:
text
mcpClientNames还会从已经连接的:
java
McpSyncClient中取得 MCP Tools,并转换成:
java
ToolCallback[]当前关键代码类似:
java
ToolCallback[] mcpToolCallBacks =
new SyncMcpToolCallbackProvider(mcpClient)
.getToolCallbacks();
CollUtil.addAll(
toolCallbacks,
mcpToolCallBacks
);所以到了 Spring AI 这一层以后普通 Java Tool 和 MCP Tool 都统一成了ToolCallback,然后再一起交给模型。
真正调用模型发生在哪里
StreamingChatModel.stream
所有准备工作完成后,会生成:
java
ChatOptions chatOptions =
AiUtils.buildChatOptions(
platform,
model.getModel(),
conversation.getTemperature(),
conversation.getMaxTokens(),
toolCallbacks,
toolContext
);
return new Prompt(
chatMessages,
chatOptions
);这里就把前面一直没有传给模型工厂的:
text
model.getModel()带进来了。
真正作为模型标识进入 ChatOptions。
不同平台会创建不同 Options。
文件:
text
backend/yudao-module-ai/src/main/java/
cn/iocoder/yudao/module/ai/util/
AiUtils.java例如 DeepSeek:
java
return DeepSeekChatOptions.builder()
.model(model)
.temperature(temperature)
.maxTokens(maxTokens)
.toolCallbacks(toolCallbacks)
.toolContext(toolContext)
.build();OpenAI:
java
return OpenAiChatOptions.builder()
.model(model)
.temperature(temperature)
.maxTokens(maxTokens)
.toolCallbacks(toolCallbacks)
.toolContext(toolContext)
.build();最终真正发起模型调用的代码只有这一行:
java
Flux<ChatResponse> streamResponse =
chatModel.stream(prompt);文件仍然是:
text
backend/yudao-module-ai/src/main/java/
cn/iocoder/yudao/module/ai/service/chat/
AiChatMessageServiceImpl.java前面那么长的准备工作,最终都汇聚到这里:
text
ChatModel
+
Prompt
↓
chatModel.stream(prompt)这一行就是当前 Chat 场景真正跨出系统、调用大模型的核心位置。
这个位置后面会非常重要。
Spring AI 返回了什么
stream() 返回的不是 String。
而是:
java
Flux<ChatResponse>每一个流式片段都是:
java
ChatResponse拿到一个 Chunk 后,先调用:
java
String newContent =
AiUtils.getChatResponseContent(chunk);
String newReasoningContent =
AiUtils.getChatResponseReasoningContent(chunk);AiUtils 的正文提取非常直接:
java
public static String getChatResponseContent(
ChatResponse response) {
if (response == null
|| response.getResult() == null
|| response.getResult().getOutput() == null) {
return null;
}
return response
.getResult()
.getOutput()
.getText();
}推理模型的 reasoningContent 则针对 DeepSeek 和部分其他模型做了兼容。
模型结果怎么回到前端
Flux 怎么变成 SSE
模型返回Flux<ChatResponse>以后,当前 Service 对每一个 Chunk 执行:
java
return streamResponse.map(chunk -> {
String newContent =
AiUtils.getChatResponseContent(chunk);
String newReasoningContent =
AiUtils.getChatResponseReasoningContent(chunk);
if (StrUtil.isNotEmpty(newContent)) {
contentBuffer.append(newContent);
}
if (StrUtil.isNotEmpty(newReasoningContent)) {
reasoningContentBuffer
.append(newReasoningContent);
}
return success(
new AiChatMessageSendRespVO()
.setSend(...)
.setReceive(
...
.setContent(
StrUtil.nullToDefault(
newContent, ""
)
)
.setReasoningContent(
StrUtil.nullToDefault(
newReasoningContent, ""
)
)
)
);
});所以每一个模型 Chunk 都被重新包装成:
text
CommonResult
↓
AiChatMessageSendRespVO然后 Controller 原样把:
java
Flux<CommonResult<...>>作为 SSE 返回。
整个路径就是:
text
Flux<ChatResponse>
↓
map()
↓
Flux<CommonResult<...>>
↓
Controller
↓
SSE前端怎么逐段接收内容
再回到:
text
frontend/src/views/ai/chat/index/index.vuefetchEventSource 每收到一个 SSE Event,就进入:
ts
async (res) => {
const { code, data, msg } =
JSON.parse(res.data)
// ...
if (data.receive.reasoningContent) {
// 拼接推理内容
}
if (data.receive.content !== '') {
receiveMessageFullText.value =
receiveMessageFullText.value
+ data.receive.content
}
await scrollToBottom()
}注意这里后端每次发送的:
text
data.receive.content只是当前这一段。
前端自己维护:
ts
receiveMessageFullText不断累加:
text
第一段:“Cost”
第二段:“Flow”
第三段:“ 是”
第四段:“一个”
...最后才形成完整回答。
为什么页面能看到打字机效果
页面中真正展示文字时,又做了一层:
ts
textRoll()它不会直接一次把:
text
receiveMessageFullText全部塞到页面。
而是根据当前:
text
已经收到多少字符
已经显示多少字符计算展示速度,然后逐字更新最后一条 Assistant Message。
因此页面表现实际上是两层流式:
text
第一层
模型 → SSE → 浏览器
真实网络流式
第二层
receiveMessageFullText
↓
textRoll()
↓
逐字显示
前端视觉流式所以页面看起来像 ChatGPT 一样逐字出现,并不代表后端真的每次只返回一个汉字。
一次聊天是怎么落库的
先保存用户消息
当前系统并不是:
text
先调模型
↓
成功以后
↓
再保存 User 和 Assistant而是在调用模型之前就开始写数据库。
仍然位于:
text
backend/yudao-module-ai/src/main/java/
cn/iocoder/yudao/module/ai/service/chat/
AiChatMessageServiceImpl.java首先:
java
AiChatMessageDO userMessage =
createChatMessage(
conversation.getId(),
null,
model,
userId,
conversation.getRoleId(),
MessageType.USER,
sendReqVO.getContent(),
sendReqVO.getUseContext(),
null,
sendReqVO.getAttachmentUrls(),
null
);createChatMessage() 最终会:
java
chatMessageMapper.insert(message);所以用户消息在真正请求大模型之前就已经存在于:
text
ai_chat_message里。
再创建 Assistant 消息
随后系统立即创建:
java
AiChatMessageDO assistantMessage =
createChatMessage(
conversation.getId(),
userMessage.getId(),
model,
userId,
conversation.getRoleId(),
MessageType.ASSISTANT,
"",
sendReqVO.getUseContext(),
knowledgeSegments,
null,
webSearchResponse
);注意这里 Assistant 的初始:
text
content = ""并且:
text
replyId = userMessage.getId()因此数据库关系已经建立:
text
User Message
id = 100
Assistant Message
id = 101
replyId = 100
content = ""然后才:
java
chatModel.stream(prompt);流式结束后更新完整内容
流式过程中,后端维护:
java
StringBuffer contentBuffer =
new StringBuffer();
StringBuffer reasoningContentBuffer =
new StringBuffer();每来一个 Chunk:
java
contentBuffer.append(newContent);等 Flux 正常结束:
java
.doOnComplete(() -> {
TenantUtils.executeIgnore(() ->
chatMessageMapper.updateById(
new AiChatMessageDO()
.setId(assistantMessage.getId())
.setContent(
contentBuffer.toString()
)
.setReasoningContent(
reasoningContentBuffer.toString()
)
)
);
})最终:
text
ai_chat_message中的 Assistant Message 才从:
text
content = ""变成:
text
content = "完整模型回答……"调用失败和主动取消怎么处理
这里当前还要考虑了两个情况:
text
模型调用出错和:
text
用户点击“停止”分别对应:
java
.doOnError(...)和:
java
.doOnCancel(...)两者处理思路基本一致。
如果模型已经返回了一部分内容:
java
if (StrUtil.isNotEmpty(contentBuffer)) {
chatMessageMapper.updateById(
new AiChatMessageDO()
.setId(assistantMessage.getId())
.setContent(contentBuffer.toString())
);
}就把已经收到的部分保存下来。
如果一个字都没有返回:
java
else {
chatMessageMapper.deleteById(
assistantMessage.getId()
);
}就把刚才提前创建的空 Assistant Message 删除。
因此当前的异常行为大概是:
text
用户消息
→ 已保存
Assistant
→ 有部分返回:保存部分内容
→ 完全没返回:删除空记录这一点对以后做 Usage 和计费尤其重要。
因为将来还必须回答:
调用失败、调用取消、只生成一半时,到底算不算一次用量?
但现在先不解决,我们后续再来处理。
这条链里已经有什么,还缺什么
现在我们已经完整走完了一次 Chat 调用。
text
index.vue
↓
ChatMessageApi
↓
AiChatMessageController
↓
AiChatMessageServiceImpl
│
├─ Conversation
├─ History
├─ Model
├─ Knowledge
├─ Web Search
├─ Tool / MCP
│
↓
buildPrompt()
↓
AiModelServiceImpl
↓
AiModelFactoryImpl
↓
StreamingChatModel.stream()
↓
Spring AI
↓
模型 Provider
↓
Flux<ChatResponse>
↓
SSE
↓
Vue而当前数据库已经记录:
text
userId
conversationId
modelId
model
用户输入
模型输出
reasoningContent
知识库 Segment
联网搜索
附件
createTime从“这是谁发出的哪一次聊天”角度看,上下文其实已经很丰富。
但现在就会发现了一个非常关键的断层。
Spring AI:
java
ChatResponse
↓
getMetadata()
↓
getUsage()可以提供统一的:
text
Prompt Tokens
Completion Tokens
Total Tokens抽象。
而当前 RuoYi Vue Pro 在处理每一个:
java
ChatResponse chunk时,只读取了:
java
AiUtils.getChatResponseContent(chunk);
AiUtils.getChatResponseReasoningContent(chunk);整个当前 Chat Service 并没有继续读取:
java
chunk.getMetadata().getUsage()也没有把:
text
promptTokens
completionTokens
totalTokens保存进:
text
ai_chat_message这和上一篇我们从数据库看到的现象完全对应起来了。
text
模型响应中
可能已经有 Usage
↓
当前业务代码只取 Content
↓
Usage 没有进入业务数据
↓
数据库自然也没有 Token 用量这个位置已经非常适合接入 CostFlow。
为什么现在还不能直接计费
看到这里可能很容易得出一个结论:
text
那就在这里 getUsage()
然后乘模型单价不就行了?但还不能这么快。
因为还有很多问题没有解决。
例如流式响应:
text
Flux<ChatResponse>会返回很多个 Chunk。
那么:
text
Usage 出现在哪个 Chunk?如果多个 Chunk 都有:
text
它是增量还是累计?不同 Provider:
text
Usage 行为是否完全一致?如果发生:
text
模型调用失败
用户取消流式请求
Tool Calling
MCP Tool Calling
一次业务请求触发多次模型调用又应该怎么处理?
另外 CostFlow 最终显然不能只绑死在:
java
AiChatMessageServiceImpl这一行代码上。
因为前面已经知道:
text
Chat
Write
Image
MindMap
Workflow未来都可能产生 AI 成本。
所以我们现在已经确定了一个非常重要的认知:
Chat 场景中真正的模型调用边界已经找到了,而且 Spring AI 响应中已经存在 Usage 抽象。
至于 CostFlow 应该怎样采集它,我们下一章再讨论。
交给 Coding Agent
这篇文章其实最适合让本地 Agent 直接沿真实源码重新走一次,而且这一次最好让 Agent 输出文件路径 + 方法名 + 调用关系,而不是泛泛解释 Spring AI。
下一步
现在我们已经不是“猜” CostFlow 应该接在哪里了。
我们真正找到了一次聊天的模型调用边界:
java
Flux<ChatResponse> streamResponse =
chatModel.stream(prompt);也确认了 Spring AI 1.1.8 的:
text
ChatResponse
↓
ChatResponseMetadata
↓
Usage本身已经提供 Token Usage 抽象。
而现有业务代码目前主要消费的是:
text
content
reasoningContent并没有把 Usage 继续变成业务数据。
下一篇就可以正式回答一个问题:
CostFlow 的第一条 Usage,到底应该在哪里、以什么方式采集?
下一篇: