package com.wok.supportbot.app;
import com.wok.supportbot.advisor.ContentSafetyAdvisor;
import com.wok.supportbot.advisor.MyLoggerAdvisor;
import com.wok.supportbot.chatmemory.DatabaseChatMemory;
import com.wok.supportbot.config.ChatModelFactory;
import com.wok.supportbot.config.SimpleCircuitBreaker;
import com.wok.supportbot.entity.AiModelConfig;
import com.wok.supportbot.entity.LlmCallTrace;
import com.wok.supportbot.mcp.McpToolCallback;
import com.wok.supportbot.mcp.McpToolCallback.ToolCallEvent;
import com.wok.supportbot.mcp.McpToolCallbackAdapter;
import com.wok.supportbot.service.AiModelConfigService;
import com.wok.supportbot.service.ContentSafetyService;
import com.wok.supportbot.service.LlmCallTraceService;
import com.fasterxml.jackson.databind.ObjectMapper;
import jakarta.annotation.Resource;
import lombok.extern.slf4j.Slf4j;
import org.springframework.ai.chat.client.ChatClient;
import org.springframework.ai.chat.client.advisor.MessageChatMemoryAdvisor;
import org.springframework.ai.chat.messages.Message;
import org.springframework.ai.chat.messages.UserMessage;
import org.springframework.ai.content.Media;
import org.springframework.ai.chat.metadata.Usage;
import org.springframework.ai.chat.model.ChatModel;
import org.springframework.ai.chat.model.ChatResponse;
import org.springframework.ai.document.Document;
import org.springframework.ai.tool.ToolCallback;
import org.springframework.ai.vectorstore.VectorStore;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.stereotype.Component;
import org.springframework.util.MimeType;
import org.springframework.util.MimeTypeUtils;
import org.springframework.util.StringUtils;
import reactor.core.Disposable;
import reactor.core.publisher.Flux;
import reactor.core.publisher.FluxSink;
import reactor.core.publisher.SignalType;
import java.util.ArrayList;
import java.util.Collections;
import java.util.LinkedHashMap;
import java.util.List;
import java.util.Map;
import java.util.UUID;
import java.util.concurrent.CopyOnWriteArrayList;
import java.util.concurrent.atomic.AtomicInteger;
import java.util.concurrent.atomic.AtomicReference;
import static org.springframework.ai.chat.memory.ChatMemory.CONVERSATION_ID;
/**
* AI 对话执行层 —— ChatClient 构建、缓存管理与 LLM 调用执行。
*
* 本类负责 ChatClient 生命周期(按 appType + allowedMcpTools 缓存,LRU 淘汰),
* Advisor 链装配(ContentSafetyAdvisor → MessageChatMemoryAdvisor → MyLoggerAdvisor),
* 以及同步/流式对话执行(含熔断保护)。对话编排决策由 {@link ChatPipeline} 完成。
*
* 核心方法:
*
* - {@link #chat(ChatContext)} — 同步对话(返回纯文本)
* - {@link #chatWithEvents(ChatContext)} — 同步对话 + MCP 工具调用事件
* - {@link #chatStream(ChatContext)} — 流式对话(返回 Flux<String>)
*
*
* {@code @pipeline} execution-layer order=2
* {@code @pipeline-step} 熔断检查: SimpleCircuitBreaker(3次失败/5分钟恢复) → 熔断时返回降级提示
* {@code @pipeline-step} Advisor链: ContentSafetyAdvisor(HIGHEST) → MessageChatMemoryAdvisor → MyLoggerAdvisor
* {@code @pipeline-step} ChatClient.call/stream: LLM 大模型调用 → 成功/失败记录到熔断器
* 同步至: frontend/src/views/PipelineFlow.vue Advisor 子图 & 熔断节点
*
* @author lyx
* @version 1.0.0
* @date 2025/06/27 14:11
*/
@Component
@Slf4j
public class AssistantApp {
@Resource
private ContentSafetyAdvisor contentSafetyAdvisor;
@Resource
private McpToolCallbackAdapter mcpToolCallbackAdapter;
@Resource
private ChatPipeline chatPipeline;
@Resource
private AiModelConfigService aiModelConfigService;
@Resource
private LlmCallTraceService llmCallTraceService;
@Resource
private ContentSafetyService contentSafetyService;
/** MCP 工具开关,默认启用,可通过 application.yml 的 chat.mcp.enabled 关闭 */
@Value("${chat.mcp.enabled:true}")
private boolean enableMcpTools;
private final ChatModelFactory chatModelFactory;
private final DatabaseChatMemory chatMemory;
/** ChatClient 缓存上限,超出后按 LRU 自动淘汰最早最少使用的实例 */
private static final int CHAT_CLIENT_CACHE_MAX = 32;
/** ChatClient 缓存(LinkedHashMap + removeEldestEntry 实现 LRU 淘汰) */
private final Map chatClientCache =
Collections.synchronizedMap(new LinkedHashMap<>(16, 0.75f, true) {
@Override
protected boolean removeEldestEntry(Map.Entry eldest) {
if (size() > CHAT_CLIENT_CACHE_MAX) {
log.info("ChatClient 缓存淘汰: key={}", eldest.getKey());
return true;
}
return false;
}
});
private final SimpleCircuitBreaker aiCircuitBreaker = new SimpleCircuitBreaker(3, 5 * 60 * 1000L, "AI");
/** 全局 AI 调用的熔断 key(非模型配置级,整体熔断) */
private static final Long AI_CIRCUIT_KEY = -1L;
/** AI 熔断时的降级提示语 */
private static final String CIRCUIT_OPEN_MESSAGE = "AI 服务暂时不可用,请稍后重试。";
private static final String SYSTEM_PROMPT = "";
/** 尾部空白缓冲上限:超过后强制发出,避免纯空白输出导致 buffer 无界增长 */
private static final int MAX_TRAILING_WHITESPACE_BUFFER = 256;
/** AI 回复落库的最大字符数,超过则保留头部 + 尾部 */
private static final int MAX_AI_RESPONSE_CHARS = 2000;
/** 截断时保留的头部字符数 */
private static final int AI_RESPONSE_HEAD_CHARS = 1500;
/** 截断时保留的尾部字符数 */
private static final int AI_RESPONSE_TAIL_CHARS = 500;
/** 埋点 JSON 序列化器(构建 ragHitsJson / toolCallsJson / historyMessagesJson) */
private static final ObjectMapper OBJECT_MAPPER = new ObjectMapper();
/** 历史消息每轮内容截断长度(避免 trace 行过大) */
private static final int HISTORY_MESSAGE_MAX_CHARS = 200;
/** 历史消息记录上限(条数) */
private static final int HISTORY_MESSAGE_MAX_COUNT = 10;
/** 错误消息截断长度 */
private static final int ERROR_MESSAGE_MAX_CHARS = 500;
/** MCP 工具调用结果落库截断长度(避免返回数据过大撑爆 trace 行) */
private static final int TOOL_CALL_RESULT_MAX_CHARS = 2000;
/** MCP 工具调用入参落库截断长度 */
private static final int TOOL_CALL_INPUT_MAX_CHARS = 500;
/**
* 埋点附加元信息:承载错误分类/消息、token 用量与 MCP 工具调用事件,
* 避免 recordTrace 参数过多。
*/
private record TraceMeta(
String errorType,
String errorMessage,
Integer promptTokens,
Integer completionTokens,
Integer totalTokens,
List mcpEvents
) {}
/**
* 初始化 ChatClient
*
* @param chatModelFactory
*/
public AssistantApp(ChatModelFactory chatModelFactory, DatabaseChatMemory chatMemory) {
this.chatModelFactory = chatModelFactory;
this.chatMemory = chatMemory;
}
private ChatClient getChatClient(String appType) {
return getChatClient(appType, enableMcpTools ? List.of("*") : null);
}
/**
* 获取 ChatClient(支持按角色过滤 MCP 工具),缓存上限 32 个,按 LRU 淘汰。
*/
private ChatClient getChatClient(String appType, List allowedMcpTools) {
// 仅 null(无角色)降级为 ["*"](向后兼容允许全部);空列表表示有角色但未授权,保持空(不注册任何工具)
final List effectiveTools;
if (enableMcpTools && allowedMcpTools == null) {
effectiveTools = List.of("*");
} else {
effectiveTools = allowedMcpTools;
}
String cacheKey = appType + ":" + (effectiveTools == null ? "none" : String.join(",", effectiveTools));
ChatClient cached = chatClientCache.get(cacheKey);
if (cached != null) return cached;
return chatClientCache.computeIfAbsent(cacheKey, key -> {
ChatModel chatModel = chatModelFactory.getChatModel(appType);
var builder = ChatClient.builder(chatModel);
if (StringUtils.hasText(SYSTEM_PROMPT)) {
builder.defaultSystem(SYSTEM_PROMPT);
}
// 注册 MCP 工具(按角色权限过滤)
if (enableMcpTools && effectiveTools != null && !effectiveTools.isEmpty()) {
ToolCallback[] mcpTools = mcpToolCallbackAdapter.getFilteredToolCallbacks(effectiveTools);
if (mcpTools.length > 0) {
builder.defaultToolCallbacks(mcpTools);
log.info("已注册 {} 个 MCP 工具到 ChatClient [{}]", mcpTools.length, cacheKey);
for (ToolCallback tc : mcpTools) {
log.info(" 工具: {} — {}", tc.getToolDefinition().name(), tc.getToolDefinition().description());
}
} else {
log.warn("MCP 工具已启用但无可注册的工具 [{}],请检查:", cacheKey);
log.warn(" 1. 是否在「MCP 服务管理」页面添加了 MCP Server 配置?");
log.warn(" 2. 配置是否已启用(is_active=true)?");
log.warn(" 3. MCP Server 是否连接成功(点击「测试连接」验证)?");
log.warn(" 4. MCP Server 是否暴露了工具(listTools 返回非空)?");
}
} else {
log.info("MCP 工具未启用 [enableMcpTools={}, effectiveTools={}]", enableMcpTools, effectiveTools);
}
return builder
.defaultAdvisors(
contentSafetyAdvisor,
MessageChatMemoryAdvisor.builder(chatMemory).build(),
new MyLoggerAdvisor()
)
.build();
});
}
public void clearCache() {
chatClientCache.clear();
log.info("AssistantApp ChatClient cache cleared");
}
/**
* 构造传给模型的用户消息:无图片时返回纯文本 UserMessage;
* 有图片时构造多模态 UserMessage(文本 + 图片 Media)。
*/
private Message buildUserMessage(ChatRequest req) {
List imageUrls = req.ctx().imageUrls();
if (imageUrls == null || imageUrls.isEmpty()) {
return new UserMessage(req.finalMessage());
}
List mediaList = imageUrls.stream()
.map(this::toImageMedia)
.filter(m -> m != null)
.toList();
if (mediaList.isEmpty()) {
return new UserMessage(req.finalMessage());
}
return UserMessage.builder()
.text(req.finalMessage())
.media(mediaList)
.build();
}
/** 将图片 URL 转换为 Media(MIME 类型按扩展名推断),URL 非法时返回 null */
private Media toImageMedia(String url) {
try {
return Media.builder()
.mimeType(resolveImageMimeType(url))
.data(url)
.build();
} catch (Exception e) {
log.warn("图片 URL 非法,跳过: {}", url);
return null;
}
}
/** 根据图片 URL 扩展名推断 MIME 类型 */
private MimeType resolveImageMimeType(String url) {
String path = url;
int q = path.indexOf('?');
if (q >= 0) {
path = path.substring(0, q);
}
int dot = path.lastIndexOf('.');
if (dot < 0) {
return MimeTypeUtils.IMAGE_PNG;
}
String ext = path.substring(dot + 1).toLowerCase();
if ("jpg".equals(ext)) {
ext = "jpeg";
}
return MimeTypeUtils.parseMimeType("image/" + ext);
}
// ==================== 统一入口(新) ====================
/**
* 同步对话(新入口,委托 {@link ChatPipeline} 编排)。
*
* @param ctx 对话上下文
* @return AI 回答文本
*/
public String chat(ChatContext ctx) {
return chatWithEvents(ctx).text();
}
/**
* 同步对话 + MCP 工具调用事件(新入口)。
* 比 {@link #chat(ChatContext)} 多返回本次触发的工具调用事件,供需要展示调用过程的场景。
*
* @param ctx 对话上下文
* @return 回答文本 + MCP 事件
*/
public ChatResult chatWithEvents(ChatContext ctx) {
long startNanos = System.nanoTime();
// 熔断:全局 AI 调用处于熔断状态,直接返回降级提示(不做 buildRequest,避免熔断期间仍走意图路由/检索)
if (aiCircuitBreaker.isOpen(AI_CIRCUIT_KEY)) {
log.warn("AI 调用熔断中,返回降级提示");
recordTrace(ctx, null, CIRCUIT_OPEN_MESSAGE, 0, "BYPASS",
new TraceMeta("CIRCUIT_BREAK", "AI 服务熔断降级", null, null, null, null));
return new ChatResult(CIRCUIT_OPEN_MESSAGE, List.of());
}
ChatRequest req = chatPipeline.buildRequest(ctx);
if (req.faqHit()) {
String faqAnswer = req.faqAnswer().get();
recordTrace(ctx, req, faqAnswer, 0, "FAQ",
new TraceMeta(null, null, null, null, null, null));
return new ChatResult(faqAnswer, List.of());
}
// 显式事件收集器 + 轮次计数器,通过 toolContext 传给 McpToolCallback,规避 Reactor 跨线程丢 ThreadLocal 的问题
List events = new CopyOnWriteArrayList<>();
AtomicInteger rounds = new AtomicInteger(0);
try {
ChatClient.ChatClientRequestSpec spec = getChatClient(ctx.appType(), ctx.allowedMcpTools())
.prompt()
.messages(buildUserMessage(req))
.advisors(s -> s.param(CONVERSATION_ID, ctx.chatId()));
if (StringUtils.hasText(req.finalSystemPrompt())) {
spec = spec.system(req.finalSystemPrompt());
}
spec = spec.toolContext(Map.of(
McpToolCallback.MCP_EVENTS_KEY, events,
McpToolCallback.MCP_ROUNDS_KEY, rounds));
ChatResponse response = spec.call().chatResponse();
String text = response.getResult().getOutput().getText();
Usage usage = response.getMetadata() != null ? response.getMetadata().getUsage() : null;
aiCircuitBreaker.recordSuccess(AI_CIRCUIT_KEY);
recordTrace(ctx, req, text, elapsedMillis(startNanos), "COMPLETE",
new TraceMeta(null, null,
usage != null ? usage.getPromptTokens() : null,
usage != null ? usage.getCompletionTokens() : null,
usage != null ? usage.getTotalTokens() : null,
events));
// 推荐问题已不再由主回复同步生成,改由 SuggestionGenerator 异步按需生成
return new ChatResult(text, events, List.of());
} catch (Exception e) {
aiCircuitBreaker.recordFailure(AI_CIRCUIT_KEY);
log.error("AI 同步调用失败: chatId={}, error={}", ctx.chatId(), e.getMessage());
String fallback = "抱歉,AI 服务调用失败:" + e.getMessage();
recordTrace(ctx, req, fallback, elapsedMillis(startNanos), "ERROR",
new TraceMeta(classifyError(e), maskError(e.getMessage()), null, null, null, events));
return new ChatResult(fallback, List.of());
}
}
/**
* 流式对话(新入口,委托 {@link ChatPipeline} 编排)。
*
* 注意:返回类型为 Flux<String>,Spring WebFlux 会对每个元素做 data: 帧包装。
* 因此 Flux 元素必须只是纯文本内容,不应包含 event: / data: 等 SSE 协议行。
*
* @param ctx 对话上下文
* @return 纯文本流式回答(每个元素为一段自然语言文本)
*/
public Flux chatStream(ChatContext ctx) {
long startNanos = System.nanoTime();
// 熔断:全局 AI 调用处于熔断状态(不做 buildRequest,避免熔断期间仍走意图路由/检索)
if (aiCircuitBreaker.isOpen(AI_CIRCUIT_KEY)) {
log.warn("AI 调用熔断中(流式),返回降级提示");
recordTrace(ctx, null, CIRCUIT_OPEN_MESSAGE, 0, "BYPASS",
new TraceMeta("CIRCUIT_BREAK", "AI 服务熔断降级", null, null, null, null));
return Flux.just(CIRCUIT_OPEN_MESSAGE);
}
ChatRequest req = chatPipeline.buildRequest(ctx);
if (req.faqHit()) {
// FAQ 命中:整段答案原样输出,由 SSE 编码器处理内部换行,
// 后端不做任何格式增删(不拆行、不加换行、不补空格)。
String faqAnswer = req.faqAnswer().get();
recordTrace(ctx, req, faqAnswer, 0, "FAQ",
new TraceMeta(null, null, null, null, null, null));
return Flux.just(faqAnswer);
}
// 显式事件收集器 + 轮次计数器,通过 toolContext 传给 McpToolCallback,规避 Reactor 跨线程丢 ThreadLocal 的问题
List events = new CopyOnWriteArrayList<>();
AtomicInteger rounds = new AtomicInteger(0);
ChatClient.ChatClientRequestSpec spec = getChatClient(ctx.appType(), ctx.allowedMcpTools())
.prompt()
.messages(buildUserMessage(req))
.advisors(s -> s.param(CONVERSATION_ID, ctx.chatId()));
if (StringUtils.hasText(req.finalSystemPrompt())) {
spec = spec.system(req.finalSystemPrompt());
}
spec = spec.toolContext(Map.of(
McpToolCallback.MCP_EVENTS_KEY, events,
McpToolCallback.MCP_ROUNDS_KEY, rounds));
// 改为 chatResponse 流以采集 token 用量,再映射回纯文本流
AtomicReference usageRef = new AtomicReference<>();
AtomicReference errorTypeRef = new AtomicReference<>();
AtomicReference errorMessageRef = new AtomicReference<>();
Flux responseFlux = spec.stream().chatResponse();
Flux rawStream = responseFlux
.doOnNext(r -> {
if (r != null && r.getMetadata() != null && r.getMetadata().getUsage() != null) {
usageRef.set(r.getMetadata().getUsage());
}
})
.map(r -> {
String out = r != null && r.getResult() != null && r.getResult().getOutput() != null
? r.getResult().getOutput().getText() : "";
return out != null ? out : "";
});
// 聚合所有分片用于埋点(在 doFinally 时取完整回复文本)
StringBuilder aggregated = new StringBuilder();
return preserveTrailingWhitespace(rawStream)
.doOnNext(aggregated::append)
.doOnComplete(() -> aiCircuitBreaker.recordSuccess(AI_CIRCUIT_KEY))
.doOnError(e -> {
aiCircuitBreaker.recordFailure(AI_CIRCUIT_KEY);
errorTypeRef.set(classifyError(e));
errorMessageRef.set(maskError(e.getMessage()));
log.error("AI 流式调用失败: chatId={}, error={}", ctx.chatId(), e.getMessage());
})
.doFinally(signalType -> {
// 流式埋点:按终止信号区分状态,断连/异常也落库(events 由 toolContext 显式收集,跨线程安全)
String status = signalType == SignalType.ON_COMPLETE ? "COMPLETE"
: signalType == SignalType.ON_ERROR ? "ERROR" : "CANCEL";
Usage usage = usageRef.get();
recordTrace(ctx, req, aggregated.toString(), elapsedMillis(startNanos), status,
new TraceMeta(errorTypeRef.get(), errorMessageRef.get(),
usage != null ? usage.getPromptTokens() : null,
usage != null ? usage.getCompletionTokens() : null,
usage != null ? usage.getTotalTokens() : null,
events));
})
.onErrorResume(e -> Flux.just("抱歉,AI 服务调用失败:" + e.getMessage()));
}
/**
* 流式对话(OpenAI Chat Completions 标准 SSE 格式)。
*
* 复用 {@link #chatStream(ChatContext)} 的完整编排逻辑(熔断早退 / FAQ 命中早退 /
* 正常流式调用 / 空白缓冲 / 埋点),差异在于把每个文本片段包装为 OpenAI 标准 JSON chunk:
* 首片 delta 携带 role=assistant,流结束时追加 finish_reason=stop 的 chunk 与 [DONE]。
*
* 每个 Flux 元素即一个完整 JSON 字符串,Spring WebFlux 自动加 data: 前缀。
*
* @param ctx 对话上下文
* @return OpenAI 标准格式的流式回答
*/
public Flux chatStreamOpenAi(ChatContext ctx) {
long startNanos = System.nanoTime();
// OpenAI 标准 chunk 的公共元信息:同一次流式回答共享 id / created / model
String completionId = "chatcmpl-" + UUID.randomUUID().toString().replace("-", "");
long created = System.currentTimeMillis() / 1000;
// 活跃模型配置可能不存在(返回 null),回退 unknown
AiModelConfig cfg = null;
try {
cfg = aiModelConfigService.getActiveConfigWithFullKey(ctx.appType());
} catch (Exception e) {
log.warn("获取活跃模型配置失败,model 回退 unknown: chatId={}, error={}", ctx.chatId(), e.getMessage());
}
String model = (cfg != null && cfg.getModelName() != null) ? cfg.getModelName() : "unknown";
// 熔断:全局 AI 调用处于熔断状态(不做 buildRequest,避免熔断期间仍走意图路由/检索)
if (aiCircuitBreaker.isOpen(AI_CIRCUIT_KEY)) {
log.warn("AI 调用熔断中(OpenAI 流式),返回降级提示");
recordTrace(ctx, null, CIRCUIT_OPEN_MESSAGE, 0, "BYPASS",
new TraceMeta("CIRCUIT_BREAK", "AI 服务熔断降级", null, null, null, null));
return openAiFallbackStream(completionId, model, created, CIRCUIT_OPEN_MESSAGE, true);
}
ChatRequest req = chatPipeline.buildRequest(ctx);
if (req.faqHit()) {
// FAQ 命中:整段答案包装为 OpenAI chunk,随后追加 stop + [DONE]
String faqAnswer = req.faqAnswer().get();
recordTrace(ctx, req, faqAnswer, 0, "FAQ",
new TraceMeta(null, null, null, null, null, null));
return openAiFallbackStream(completionId, model, created, faqAnswer, true);
}
// 显式事件收集器 + 轮次计数器,通过 toolContext 传给 McpToolCallback,规避 Reactor 跨线程丢 ThreadLocal 的问题
List events = new CopyOnWriteArrayList<>();
AtomicInteger rounds = new AtomicInteger(0);
ChatClient.ChatClientRequestSpec spec = getChatClient(ctx.appType(), ctx.allowedMcpTools())
.prompt()
.messages(buildUserMessage(req))
.advisors(s -> s.param(CONVERSATION_ID, ctx.chatId()));
if (StringUtils.hasText(req.finalSystemPrompt())) {
spec = spec.system(req.finalSystemPrompt());
}
spec = spec.toolContext(Map.of(
McpToolCallback.MCP_EVENTS_KEY, events,
McpToolCallback.MCP_ROUNDS_KEY, rounds));
// 改为 chatResponse 流以采集 token 用量,再映射回纯文本流
AtomicReference usageRef = new AtomicReference<>();
AtomicReference errorTypeRef = new AtomicReference<>();
AtomicReference errorMessageRef = new AtomicReference<>();
Flux responseFlux = spec.stream().chatResponse();
Flux rawStream = responseFlux
.doOnNext(r -> {
if (r != null && r.getMetadata() != null && r.getMetadata().getUsage() != null) {
usageRef.set(r.getMetadata().getUsage());
}
})
.map(r -> {
String out = r != null && r.getResult() != null && r.getResult().getOutput() != null
? r.getResult().getOutput().getText() : "";
return out != null ? out : "";
});
// 聚合所有分片用于埋点(在 doFinally 时取完整回复文本)
StringBuilder aggregated = new StringBuilder();
return preserveTrailingWhitespace(rawStream)
.doOnNext(aggregated::append)
.map(chunk -> buildOpenAiChunk(completionId, model, created, chunk, false, null))
.doOnComplete(() -> aiCircuitBreaker.recordSuccess(AI_CIRCUIT_KEY))
.doOnError(e -> {
aiCircuitBreaker.recordFailure(AI_CIRCUIT_KEY);
errorTypeRef.set(classifyError(e));
errorMessageRef.set(maskError(e.getMessage()));
log.error("AI 流式调用失败(OpenAI): chatId={}, error={}", ctx.chatId(), e.getMessage());
})
.doFinally(signalType -> {
// 流式埋点:按终止信号区分状态,断连/异常也落库(events 由 toolContext 显式收集,跨线程安全)
String status = signalType == SignalType.ON_COMPLETE ? "COMPLETE"
: signalType == SignalType.ON_ERROR ? "ERROR" : "CANCEL";
Usage usage = usageRef.get();
recordTrace(ctx, req, aggregated.toString(), elapsedMillis(startNanos), status,
new TraceMeta(errorTypeRef.get(), errorMessageRef.get(),
usage != null ? usage.getPromptTokens() : null,
usage != null ? usage.getCompletionTokens() : null,
usage != null ? usage.getTotalTokens() : null,
events));
})
// 首片(仅 role=assistant、无 content)在流订阅时立即发出,确保 SSE 响应头/首字节及时 flush。
// 推理模型(如 doubao-seed)思考阶段 delta.content 为空、被 preserveTrailingWhitespace 吞掉,
// 若不提前发首片,思考阶段将无任何字节输出,前端等待首字节会触发 60s 超时。
.startWith(buildOpenAiChunk(completionId, model, created, "", true, null))
// 流正常结束时追加 finish_reason=stop 的 chunk 与 [DONE]
.concatWith(Flux.just(
buildOpenAiChunk(completionId, model, created, "", false, "stop"),
"[DONE]"))
// 错误兜底:脱敏错误信息,避免泄露内部细节(首片 role 已提前发出,此处不再带 role)
.onErrorResume(e -> openAiFallbackStream(completionId, model, created,
"抱歉,AI 服务调用失败:" + maskError(e.getMessage()), false));
}
/**
* 组装单个 OpenAI Chat Completions 流式 chunk(JSON 字符串)。
*
* 字段顺序固定为 id/object/created/model/choices,delta 内 role 在 content 前;
* choices 使用 LinkedHashMap 以支持 finish_reason=null(Map.of 不允许 null 值)。
*
* @param id chunk 唯一 ID(chatcmpl-xxx)
* @param model 模型名称
* @param created 创建时间(epoch 秒)
* @param content 文本片段(stop 片传空串)
* @param first 是否首片(首片 delta 携带 role=assistant)
* @param finishReason 结束原因(中间片为 null,stop 片为 "stop")
* @return OpenAI 标准 chunk 的 JSON 字符串
*/
private String buildOpenAiChunk(String id, String model, long created, String content, boolean first, String finishReason) {
// delta:首片带 role=assistant(role 在 content 前),后续片仅 content,stop 片为空对象
Map delta = new LinkedHashMap<>();
if (first) {
delta.put("role", "assistant");
}
if (content != null && !content.isEmpty()) {
delta.put("content", content);
}
Map choice = new LinkedHashMap<>();
choice.put("index", 0);
choice.put("delta", delta);
choice.put("finish_reason", finishReason);
Map chunk = new LinkedHashMap<>();
chunk.put("id", id);
chunk.put("object", "chat.completion.chunk");
chunk.put("created", created);
chunk.put("model", model);
chunk.put("choices", List.of(choice));
try {
return OBJECT_MAPPER.writeValueAsString(chunk);
} catch (Exception e) {
log.warn("序列化 OpenAI chunk 失败: {}", e.getMessage());
return "{}";
}
}
/**
* 组装 OpenAI 格式的早退/兜底流:内容 chunk + finish_reason=stop + [DONE]。
* 用于熔断降级、FAQ 命中与错误兜底三种场景。
*
* @param id chunk 唯一 ID
* @param model 模型名称
* @param created 创建时间(epoch 秒)
* @param content 完整回复文本
* @param withRole 首片是否携带 role=assistant(熔断/FAQ 早退为 true;错误兜底时取决于此前是否已发出内容片)
* @return OpenAI 标准格式的流
*/
private Flux openAiFallbackStream(String id, String model, long created, String content, boolean withRole) {
return Flux.just(
buildOpenAiChunk(id, model, created, content, withRole, null),
buildOpenAiChunk(id, model, created, "", false, "stop"),
"[DONE]");
}
/**
* 缓冲以空白字符结尾的 chunk,将其与下一个 chunk 合并后再发出。
*
* 原因:前端 SSE 解析会对每行执行 trim(),如果 chunk 以空白结尾(如 Markdown 标题 "## "),
* 行尾空白会被削掉,导致 "## " + "一、..." 拼成 "##一、...",破坏 Markdown 渲染。
* 本方法不改变大模型输出的文本内容,只调整 chunk 边界以避开 SSE 的空白截断。
*
* @param source 原始大模型输出流
* @return 调整后的流,每个元素均不以空白字符结尾(流末尾除外)
*/
private static Flux preserveTrailingWhitespace(Flux source) {
return Flux.create(sink -> {
StringBuilder buffer = new StringBuilder();
// 捕获内部订阅,下游取消/释放时同步取消上游,避免资源泄漏
Disposable subscription = source.subscribe(
chunk -> {
if (chunk == null) {
return;
}
buffer.append(chunk);
String current = buffer.toString();
if (current.isEmpty()) {
return;
}
char last = current.charAt(current.length() - 1);
// 当前累积内容不以空白结尾,可以安全发出
if (!Character.isWhitespace(last)) {
sink.next(current);
buffer.setLength(0);
} else if (buffer.length() >= MAX_TRAILING_WHITESPACE_BUFFER) {
// 尾部空白过长(如纯空白输出),强制发出以限制内存占用
sink.next(current);
buffer.setLength(0);
}
// 若以空白结尾,继续缓存,等下一个 chunk
},
e -> {
// 出错前先把已缓冲内容发出,避免已生成文本丢失
if (buffer.length() > 0) {
sink.next(buffer.toString());
buffer.setLength(0);
}
sink.error(e);
},
() -> {
if (buffer.length() > 0) {
sink.next(buffer.toString());
}
sink.complete();
}
);
sink.onCancel(subscription);
sink.onDispose(subscription);
}, FluxSink.OverflowStrategy.BUFFER);
}
/**
* 组装并异步写入一条 LLM 调用追踪记录。
*
* 所有字段均在调用现场(非异步线程)组装为实体,避免把 ChatRequest/ChatContext
* 全量对象塞进异步队列。落库前对用户消息与 AI 回复做敏感词脱敏与长度截断。
*
* @param ctx 对话上下文
* @param req 编排决策(熔断早退时为 null)
* @param responseText AI 回复文本
* @param latencyMs 耗时(毫秒)
* @param status 状态:COMPLETE / ERROR / CANCEL / FAQ / BYPASS
*/
private void recordTrace(ChatContext ctx, ChatRequest req, String responseText, long latencyMs,
String status, TraceMeta meta) {
try {
// 走缓存的活跃配置(仅取模型元信息,不落 apiKey),避免每次对话在 Reactor 线程同步查库
AiModelConfig cfg = aiModelConfigService.getActiveConfigWithFullKey(ctx.appType());
// AI 回复脱敏 + 截断(保留头部 + 尾部,避免丢失末尾的推荐问题块)
String maskedResponse = contentSafetyService.mask(responseText);
String aiResponse = maskedResponse;
boolean truncated = false;
if (maskedResponse != null && maskedResponse.length() > MAX_AI_RESPONSE_CHARS) {
aiResponse = maskedResponse.substring(0, AI_RESPONSE_HEAD_CHARS)
+ "\n\n…(内容过长已截断)…\n\n"
+ maskedResponse.substring(maskedResponse.length() - AI_RESPONSE_TAIL_CHARS);
truncated = true;
}
// FAQ 命中详情(faqMatchResult 来自编排决策,未命中为 null)
Long faqId = null;
String faqQuestion = null;
String faqMatchType = null;
Double faqScore = null;
if (req != null && req.faqMatchResult() != null && req.faqMatchResult().getFaq() != null) {
faqId = req.faqMatchResult().getFaq().getId();
faqQuestion = req.faqMatchResult().getFaq().getQuestion();
faqMatchType = req.faqMatchResult().getMatchType();
faqScore = req.faqMatchResult().getScore();
}
// 历史消息(一次读取,同时用于 JSON 与条数)
List history = safeGetHistory(ctx.chatId());
LlmCallTrace trace = LlmCallTrace.builder()
.conversationId(ctx.chatId())
.roleId(ctx.roleId())
.roleName(ctx.roleName())
.accountId(ctx.accountId())
.apiKeyId(ctx.apiKeyId())
.intent(req != null ? req.intent() : null)
.enableRag(ctx.enableRag())
.systemPrompt(req != null ? contentSafetyService.mask(req.finalSystemPrompt()) : null)
.globalPrompt(req != null ? contentSafetyService.mask(req.globalPrompt()) : null)
.rolePrompt(contentSafetyService.mask(ctx.systemPrompt()))
.userMessage(contentSafetyService.mask(ctx.message()))
.aiResponse(aiResponse)
.aiResponseTruncated(truncated)
.ragContext(req != null ? contentSafetyService.mask(req.ragContextText()) : null)
.ragHitsJson(buildRagHitsJson(req))
.faqHit(req != null ? req.faqHit() : false)
.faqId(faqId)
.faqQuestion(faqQuestion)
.faqMatchType(faqMatchType)
.faqScore(faqScore)
.searchMode(req != null ? req.searchMode() : null)
.hitCount(req != null ? req.hitCount() : null)
.toolCallsJson(buildToolCallsJson(meta != null ? meta.mcpEvents() : null))
.historyMessagesJson(buildHistoryJson(history))
.historyTurns(history != null ? history.size() : null)
.modelName(cfg != null ? cfg.getModelName() : null)
.provider(cfg != null ? cfg.getProvider() : null)
.temperature(cfg != null ? cfg.getTemperature() : null)
.maxTokens(cfg != null ? cfg.getMaxTokens() : null)
.promptTokens(meta != null ? meta.promptTokens() : null)
.completionTokens(meta != null ? meta.completionTokens() : null)
.totalTokens(meta != null ? meta.totalTokens() : null)
.errorType(meta != null ? meta.errorType() : null)
.errorMessage(meta != null ? contentSafetyService.mask(meta.errorMessage()) : null)
.latencyMs((int) latencyMs)
.status(status)
.build();
llmCallTraceService.recordAsync(trace);
} catch (Exception e) {
log.warn("构造 LLM 调用追踪失败(不影响主流程): chatId={}, error={}", ctx.chatId(), e.getMessage());
}
}
/**
* 序列化 RAG 命中文档片段为 JSON(含 documentId/title/chunkIndex/sourceName/score/searchMode)。
* 无命中返回 null。
*/
private String buildRagHitsJson(ChatRequest req) {
if (req == null || req.hitDocuments() == null || req.hitDocuments().isEmpty()) {
return null;
}
try {
List