From 7ce56e5f77c6d54913a4d717a1dfd411aaebefa1 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E4=B8=B0=E4=BD=B3?= Date: Tue, 9 Jun 2026 17:27:32 +0800 Subject: [PATCH 01/11] =?UTF-8?q?docs(ai):=20=E4=B8=BA=20AgentUtilsConfigu?= =?UTF-8?q?ration=20=E6=B7=BB=E5=8A=A0=E6=96=B9=E6=B3=95=E6=B3=A8=E9=87=8A?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 为 interviewSkillsToolCallback 方法添加 JavaDoc 注释 - 为 normalizeSkillsRoot 路径处理步骤添加内联注释 - 为技能目录不存在时的校验逻辑补充说明 --- .../guide/common/ai/AgentUtilsConfiguration.java | 15 +++++++++++++++ 1 file changed, 15 insertions(+) diff --git a/app/src/main/java/interview/guide/common/ai/AgentUtilsConfiguration.java b/app/src/main/java/interview/guide/common/ai/AgentUtilsConfiguration.java index 439a5d6f..e4fcd205 100644 --- a/app/src/main/java/interview/guide/common/ai/AgentUtilsConfiguration.java +++ b/app/src/main/java/interview/guide/common/ai/AgentUtilsConfiguration.java @@ -26,12 +26,17 @@ public AgentUtilsConfiguration( this.agentUtilsProperties = agentUtilsProperties; } + /** + * 创建 SkillsTool,并注册为 Spring Bean。 + */ @Bean("interviewSkillsToolCallback") public ToolCallback interviewSkillsToolCallback() { String configuredSkillsRoot = agentUtilsProperties.getSkillsRoot(); + // 规范化路径:处理 null/空白、\\ 分隔符、/SKILL.md 后缀、通配符、末尾斜杠等边界情况 String normalizedSkillsRoot = normalizeSkillsRoot(configuredSkillsRoot); Resource skillsRootResource = resourceLoader.getResource(normalizedSkillsRoot); + // 技能目录不存在时直接拦停启动,避免 Agent 调用时静默报错难以排查 if (!skillsRootResource.exists()) { throw new IllegalStateException("未找到 skills 根目录,请检查配置: " + normalizedSkillsRoot); } @@ -43,7 +48,13 @@ public ToolCallback interviewSkillsToolCallback() { .build(); } + /** + * 规范化 skills 路径:兼容用户配置可能携带的各种后缀和格式。 + * 用户可能配置 classpath:skills、classpath:skills/*、classpath:skills/SKILL.md 等变体, + * 统一归约到目录形式后再交给 ResourceLoader。 + */ private String normalizeSkillsRoot(String raw) { + // 未配置时使用默认值,确保 SkillsTool 能找到内置技能 if (raw == null || raw.isBlank()) { return "classpath:skills"; } @@ -51,15 +62,19 @@ private String normalizeSkillsRoot(String raw) { String normalized = raw.trim(); normalized = normalized.replace('\\', '/'); + // 用户可能直接指向某个 SKILL.md 文件而非目录 if (normalized.endsWith("/SKILL.md")) { + // 去掉 SKILL.md normalized = normalized.substring(0, normalized.length() - "/SKILL.md".length()); } + // 去掉 classpath:skills/* 中的通配符,SkillsTool 会扫描子目录 int wildcardIndex = normalized.indexOf('*'); if (wildcardIndex >= 0) { normalized = normalized.substring(0, wildcardIndex); } + // 去掉末尾的 / while (normalized.endsWith("/")) { normalized = normalized.substring(0, normalized.length() - 1); } From a710ab847f273cb2f98a849dae8bbdb5760efae0 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E4=B8=B0=E4=BD=B3?= Date: Tue, 9 Jun 2026 17:49:04 +0800 Subject: [PATCH 02/11] =?UTF-8?q?refactor(ai):=20=E4=BD=BF=E7=94=A8=20JdkC?= =?UTF-8?q?lientHttpRequestFactory=20=E6=9B=BF=E4=BB=A3=20SimpleClientHttp?= =?UTF-8?q?RequestFactory?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 将超时配置从 int 毫秒值迁移为 Duration 类型,提升可读性 - 补充 ApiPathResolver 类文档,说明 baseUrl 版本段识别逻辑 - 调整 import 顺序以符合规范 --- .../guide/common/ai/ApiPathResolver.java | 33 +++++++++++++++---- .../guide/common/ai/ApiPathResolverTest.java | 4 ++- 2 files changed, 30 insertions(+), 7 deletions(-) diff --git a/app/src/main/java/interview/guide/common/ai/ApiPathResolver.java b/app/src/main/java/interview/guide/common/ai/ApiPathResolver.java index 9d50c46a..5b8ce323 100644 --- a/app/src/main/java/interview/guide/common/ai/ApiPathResolver.java +++ b/app/src/main/java/interview/guide/common/ai/ApiPathResolver.java @@ -1,16 +1,28 @@ package interview.guide.common.ai; import org.springframework.ai.openai.api.OpenAiApi; -import org.springframework.http.client.SimpleClientHttpRequestFactory; +import org.springframework.http.client.JdkClientHttpRequestFactory; import org.springframework.web.client.RestClient; +import java.net.http.HttpClient; +import java.time.Duration; import java.util.regex.Pattern; +/** + * OpenAI API 客户端构造器。 + * 不同 LLM Provider 的 baseUrl 结构不同: + * - OpenAI 系:https://api.openai.com/v1 → API 路径为 /v1/chat/completions + * - Ollama 等:http://localhost:11434 → 路径就是 /chat/completions(无版本前缀) + * 此类自动识别 baseUrl 中是否已包含版本段,决定是否需要补全 API 路径。 + */ public final class ApiPathResolver { - private static final int DEFAULT_CONNECT_TIMEOUT = 10000; - private static final int DEFAULT_READ_TIMEOUT = 300000; + // 连接超时 10s:网络握手不应等待过久 + private static final Duration DEFAULT_CONNECT_TIMEOUT = Duration.ofSeconds(10); + // 读取超时 5min:LLM 流式响应可能持续较长时间 + private static final Duration DEFAULT_READ_TIMEOUT = Duration.ofMinutes(5); + // 匹配 baseUrl 末尾的 /v1、/v1beta 等版本段 private static final Pattern TRAILING_VERSION = Pattern.compile("/v\\d+[a-zA-Z0-9]*$"); private ApiPathResolver() {} @@ -20,9 +32,15 @@ public static OpenAiApi buildOpenAiApi(String baseUrl, String apiKey) { } public static OpenAiApi buildOpenAiApi(String baseUrl, String apiKey, - int connectTimeout, int readTimeout) { - SimpleClientHttpRequestFactory requestFactory = new SimpleClientHttpRequestFactory(); - requestFactory.setConnectTimeout(connectTimeout); + Duration connectTimeout, Duration readTimeout) { + // 自定义超时:RestClient 默认无超时,LLM 请求必须设置合理超时避免线程挂死 + // 使用 JdkClientHttpRequestFactory 替代 SimpleClientHttpRequestFactory: + // - Java 21 内置 HttpClient 原生支持 HTTP/2 和连接池(keep-alive) + // - 零额外依赖,项目 Java 21 + Spring Boot 4.0 天然适配 + HttpClient httpClient = HttpClient.newBuilder() + .connectTimeout(connectTimeout) + .build(); + JdkClientHttpRequestFactory requestFactory = new JdkClientHttpRequestFactory(httpClient); requestFactory.setReadTimeout(readTimeout); RestClient.Builder restClientBuilder = RestClient.builder() @@ -32,12 +50,14 @@ public static OpenAiApi buildOpenAiApi(String baseUrl, String apiKey, .baseUrl(baseUrl) .apiKey(apiKey) .restClientBuilder(restClientBuilder); + // baseUrl 末尾含版本段(如 /v1)时,API 路径不再拼接版本前缀 if (baseUrlContainsVersion(baseUrl)) { apiBuilder.completionsPath("/chat/completions").embeddingsPath("/embeddings"); } return apiBuilder.build(); } + /** 判断 baseUrl 末尾是否包含 /v1 之类的版本段。 */ public static boolean baseUrlContainsVersion(String baseUrl) { if (baseUrl == null || baseUrl.isBlank()) { return false; @@ -46,6 +66,7 @@ public static boolean baseUrlContainsVersion(String baseUrl) { return TRAILING_VERSION.matcher(stripped).find(); } + /** 去除字符串末尾的斜杠,便于路径拼接和正则匹配。 */ public static String stripTrailingSlashes(String value) { if (value == null) { return ""; diff --git a/app/src/test/java/interview/guide/common/ai/ApiPathResolverTest.java b/app/src/test/java/interview/guide/common/ai/ApiPathResolverTest.java index 3febcebe..e31c5368 100644 --- a/app/src/test/java/interview/guide/common/ai/ApiPathResolverTest.java +++ b/app/src/test/java/interview/guide/common/ai/ApiPathResolverTest.java @@ -4,6 +4,8 @@ import org.junit.jupiter.api.Nested; import org.junit.jupiter.api.Test; +import java.time.Duration; + import static org.assertj.core.api.Assertions.assertThat; class ApiPathResolverTest { @@ -132,7 +134,7 @@ void plainBaseUrlUsesDefaultPaths() { @DisplayName("自定义超时参数生效") void customTimeouts() { var api = ApiPathResolver.buildOpenAiApi( - "https://api.openai.com", "test-key", 5000, 60000); + "https://api.openai.com", "test-key", Duration.ofSeconds(5), Duration.ofMinutes(1)); assertThat(api).isNotNull(); } From 8fc761298e1703327df9b3cf2dd745c0af9b9558 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E4=B8=B0=E4=BD=B3?= Date: Wed, 10 Jun 2026 11:23:12 +0800 Subject: [PATCH 03/11] =?UTF-8?q?docs(ai):=20=E8=A1=A5=E5=85=85=20ApiPathR?= =?UTF-8?q?esolver=20=E5=92=8C=20LlmProviderRegistry=20=E7=B1=BB=E6=B3=A8?= =?UTF-8?q?=E9=87=8A?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - ApiPathResolver: 将 URL 示例用 {@code} 包裹,改进 Javadoc 格式 - LlmProviderRegistry: 用中文详细描述注册中心职责,包括三层缓存策略和三种客户端场景 --- .../guide/common/ai/ApiPathResolver.java | 4 +- .../guide/common/ai/LlmProviderRegistry.java | 105 +++++++++++++++++- 2 files changed, 104 insertions(+), 5 deletions(-) diff --git a/app/src/main/java/interview/guide/common/ai/ApiPathResolver.java b/app/src/main/java/interview/guide/common/ai/ApiPathResolver.java index 5b8ce323..cbe0f9af 100644 --- a/app/src/main/java/interview/guide/common/ai/ApiPathResolver.java +++ b/app/src/main/java/interview/guide/common/ai/ApiPathResolver.java @@ -11,8 +11,8 @@ /** * OpenAI API 客户端构造器。 * 不同 LLM Provider 的 baseUrl 结构不同: - * - OpenAI 系:https://api.openai.com/v1 → API 路径为 /v1/chat/completions - * - Ollama 等:http://localhost:11434 → 路径就是 /chat/completions(无版本前缀) + * - OpenAI 系:{@code https://api.openai.com/v1} → API 路径为 /v1/chat/completions + * - Ollama 等:{@code http://localhost:11434} → 路径就是 /chat/completions(无版本前缀) * 此类自动识别 baseUrl 中是否已包含版本段,决定是否需要补全 API 路径。 */ public final class ApiPathResolver { diff --git a/app/src/main/java/interview/guide/common/ai/LlmProviderRegistry.java b/app/src/main/java/interview/guide/common/ai/LlmProviderRegistry.java index 8e951538..1107f7c5 100644 --- a/app/src/main/java/interview/guide/common/ai/LlmProviderRegistry.java +++ b/app/src/main/java/interview/guide/common/ai/LlmProviderRegistry.java @@ -40,14 +40,20 @@ import java.util.concurrent.ConcurrentHashMap; /** - * Registry for managing and caching LLM providers. - * Supports dynamic creation of ChatClient based on provider configurations. + * LLM Provider 注册中心,负责 ChatClient / EmbeddingModel 的创建、缓存和路由。 + * + * 职责: + * - 按 providerId 创建并缓存 ChatClient,避免重复创建底层 ChatModel + * - 区分 通用/纯文本(plain)/语音(voice) 三种客户端场景,各自装配不同的 Advisor + * - Provider 配置支持 DB 和配置文件两种来源,DB 优先 + * - 运行时支持 reload 重新加载(配置热更新) */ @Component @Slf4j public class LlmProviderRegistry { private final LlmProviderProperties properties; + // 三层缓存,按粒度拆分:ChatModel 和 EmbeddingModel 可被不同 Client 共享复用 private final Map clientCache = new ConcurrentHashMap<>(); private final Map chatModelCache = new ConcurrentHashMap<>(); private final Map embeddingModelCache = new ConcurrentHashMap<>(); @@ -58,6 +64,7 @@ public class LlmProviderRegistry { private final ToolCallingManager toolCallingManager; private final ObservationRegistry observationRegistry; private final ToolCallback interviewSkillsToolCallback; + // 各厂商推荐的 Embedding 模型名映射,用于用户误配聊天模型时给出具体建议 private static final Map RECOMMENDED_EMBEDDING_MODELS = Map.of( "dashscope", "text-embedding-v3", "glm", "embedding-3", @@ -72,6 +79,8 @@ public LlmProviderRegistry( LlmProviderRepository providerRepository, LlmGlobalSettingRepository globalSettingRepository, ApiKeyEncryptionService encryptionService, + // 以下三个入参依赖第三方 jar(spring-ai、spring-ai-agent-utils), + // 用 required=false 避免它们不在类路径时启动失败 @Autowired(required = false) ToolCallingManager toolCallingManager, @Autowired(required = false) ObservationRegistry observationRegistry, @Autowired(required = false) @Qualifier("interviewSkillsToolCallback") ToolCallback interviewSkillsToolCallback) { @@ -84,6 +93,7 @@ public LlmProviderRegistry( this.interviewSkillsToolCallback = interviewSkillsToolCallback; } + // 无 DB 依赖的构造器重载,供单测或无需 DB 持久化的环境使用 public LlmProviderRegistry( LlmProviderProperties properties, ToolCallingManager toolCallingManager, @@ -132,6 +142,7 @@ public ChatClient getChatClientOrDefault(String providerId) { */ public ChatClient getPlainChatClient(String providerId) { String id = resolveProviderId(providerId); + // ":plain" 后缀与通用 ChatClient 隔离,避免缓存污染 return clientCache.computeIfAbsent(id + ":plain", key -> createPlainChatClient(id)); } @@ -141,11 +152,13 @@ public ChatClient getPlainChatClient(String providerId) { */ public ChatClient getVoiceChatClient(String providerId) { String id = resolveProviderId(providerId); + // ":voice" 后缀与通用/plain ChatClient 隔离 return clientCache.computeIfAbsent(id + ":voice", key -> createVoiceChatClient(id)); } /** * 清空缓存,重新加载所有 provider。 + * 配置页修改后调用,下次请求重建 ChatClient。 */ public void reload() { int size = clientCache.size() + chatModelCache.size() + embeddingModelCache.size(); @@ -155,6 +168,9 @@ public void reload() { log.info("[LlmProviderRegistry] Cache cleared ({} entries). Next access will re-create clients.", size); } + /** + * 获取 EmbeddingModel(用于知识库向量化)。 + */ public EmbeddingModel getEmbeddingModel(String providerId) { return embeddingModelCache.computeIfAbsent(providerId, id -> { log.info("[LlmProviderRegistry] Creating new embedding model for provider: {}", id); @@ -162,14 +178,24 @@ public EmbeddingModel getEmbeddingModel(String providerId) { }); } + /** + * 获取默认的 EmbeddingModel。 + * 优先使用 app.ai.default-embedding-provider,未配置时退化为 default-provider。 + */ public EmbeddingModel getDefaultEmbeddingModel() { return getEmbeddingModel(resolveDefaultEmbeddingProviderId()); } + // ---- 三种 ChatClient 创建 ---- + + /** + * 创建通用 ChatClient:SkillsTool + 配置装配的 Advisor 列表。 + */ private ChatClient createChatClient(String providerId) { OpenAiChatModel chatModel = getChatModel(providerId); ChatClient.Builder builder = ChatClient.builder(chatModel); + // SkillsTool 可选依赖,不存在时跳过(如测试环境) if (interviewSkillsToolCallback != null) { builder.defaultToolCallbacks(interviewSkillsToolCallback); } @@ -182,14 +208,22 @@ private ChatClient createChatClient(String providerId) { return builder.build(); } + /** + * 纯文本 ChatClient:仅保留敏感词护卫,不带工具调用和记忆。 + * 用于出题、评分等结构化输出场景——工具调用消息会干扰模型生成纯净 JSON。 + */ private ChatClient createPlainChatClient(String providerId) { OpenAiChatModel chatModel = getChatModel(providerId); ChatClient.Builder builder = ChatClient.builder(chatModel); - buildSafeGuardAdvisor().ifPresent(advisor -> builder.defaultAdvisors(advisor)); + buildSafeGuardAdvisor().ifPresent(builder::defaultAdvisors); log.info("[LlmProviderRegistry] Created plain ChatClient (no tools) for {}", providerId); return builder.build(); } + /** + * 语音面试 ChatClient:SkillsTool + 流式 ToolCallAdvisor。 + * 不加 Memory Advisor——语音面试按对话轮次管理历史,不走 Spring AI 的窗口记忆。 + */ private ChatClient createVoiceChatClient(String providerId) { OpenAiChatModel chatModel = getChatModel(providerId); @@ -209,6 +243,11 @@ private ChatClient createVoiceChatClient(String providerId) { return builder.build(); } + // ---- Model 创建与缓存 ---- + + /** + * 按 provider 缓存 ChatModel,避免相同 Provider 的 ChatClient 反复重建底层模型。 + */ private OpenAiChatModel getChatModel(String providerId) { return chatModelCache.computeIfAbsent(providerId, id -> { log.info("[LlmProviderRegistry] Creating new ChatModel for provider: {}", id); @@ -216,6 +255,10 @@ private OpenAiChatModel getChatModel(String providerId) { }); } + /** + * 构造 OpenAiChatModel。 + * temperature 默认 0.2,偏向确定性和可复现的面试评估——过高 temperature 的输出难以评分。 + */ private OpenAiChatModel buildChatModel(String providerId) { ProviderSnapshot config = loadProviderOrThrow(providerId); log.info("[LlmProviderRegistry] Building ChatModel - Provider: {}, BaseUrl: {}, Model: {}", @@ -233,10 +276,17 @@ private OpenAiChatModel buildChatModel(String providerId) { options, toolCallingManager, RetryUtils.DEFAULT_RETRY_TEMPLATE, + // observationRegistry 可能为 null(required=false),降级为 NOOP 避免 NPE observationRegistry != null ? observationRegistry : ObservationRegistry.NOOP ); } + /** + * 创建 EmbeddingModel(知识库向量化)。 + * 创建前校验: + * - 未配置 Embedding 模型时抛异常 + * - 用户可能误填聊天模型名,通过 looksLikeChatModel 预先拦截并给出推荐修正 + */ private EmbeddingModel createEmbeddingModel(String providerId) { ProviderSnapshot config = loadProviderOrThrow(providerId); if (!config.supportsEmbedding() || isBlank(config.embeddingModel())) { @@ -270,6 +320,12 @@ private EmbeddingModel createEmbeddingModel(String providerId) { ); } + // ---- Advisor 装配 ---- + + /** + * 按配置逐项装配 Advisor。 + * 工具调用 → 对话记忆 → 请求日志 → 敏感词护卫,全由 application.yml 控制开关。 + */ private List buildDefaultAdvisors(String providerId) { AdvisorConfig config = properties.getAdvisors(); if (config == null || !config.isEnabled()) { @@ -289,6 +345,7 @@ private List buildDefaultAdvisors(String providerId) { } if (config.isMessageChatMemoryEnabled()) { + // 窗口记忆最少保留 20 条,避免记忆太短对话失去上下文 int maxMessages = Math.max(20, config.getMessageChatMemoryMaxMessages()); MessageChatMemoryAdvisor memoryAdvisor = MessageChatMemoryAdvisor.builder( MessageWindowChatMemory.builder() @@ -307,6 +364,13 @@ private List buildDefaultAdvisors(String providerId) { return advisors; } + /** + * 构建 ToolCallAdvisor。 + * 配置项: + * - toolCallingManager:工具调用管理器,由 Spring 创建 + * - conversationHistoryEnabled:是否启用对话记忆 + * - streamToolCallResponses:是否流式返回工具调用结果 + */ private ToolCallAdvisor buildToolCallAdvisor(boolean conversationHistoryEnabled, boolean streamToolCallResponses) { return ToolCallAdvisor.builder() @@ -316,6 +380,10 @@ private ToolCallAdvisor buildToolCallAdvisor(boolean conversationHistoryEnabled, .build(); } + /** + * 敏感词护卫:拦截求职者试图让 AI 做面试无关的事情(写代码、聊天等)。 + * 提示词设计为"只能协助面试相关任务",保持边界安全。 + */ private Optional buildSafeGuardAdvisor() { AdvisorConfig config = properties.getAdvisors(); if (config == null || !config.isSafeguardEnabled()) { @@ -329,11 +397,17 @@ private Optional buildSafeGuardAdvisor() { return Optional.of(advisor); } + // ---- Provider ID 解析(DB 优先,properties 兜底) ---- + private String resolveProviderId(String providerId) { return (providerId != null && !providerId.isBlank()) ? providerId : resolveDefaultChatProviderId(); } + /** + * 从 DB 查询默认聊天 Provider,查询不到时用配置文件的默认值。 + * providerRepository 为 null 说明是无 DB 环境(测试/配置驱动模式),直接返回配置默认值。 + */ private String resolveDefaultChatProviderId() { if (globalSettingRepository == null) { return properties.getDefaultProvider(); @@ -344,6 +418,10 @@ private String resolveDefaultChatProviderId() { .orElse(properties.getDefaultProvider()); } + /** + * 解析默认 Embedding Provider:优先查 DB,未配置时退化为 default-provider。 + * 比聊天 Provider 多一层 fallback:先查 default-embedding-provider,再退化为 default-provider。 + */ private String resolveDefaultEmbeddingProviderId() { if (globalSettingRepository == null) { return !isBlank(properties.getDefaultEmbeddingProvider()) @@ -358,6 +436,11 @@ private String resolveDefaultEmbeddingProviderId() { : properties.getDefaultProvider()); } + + /** + * 加载 Provider 配置:DB 有数据则走 DB(支持加密存储的 API Key),否则退化为配置文件。 + * providerRepository 为 null 时直接走配置文件路径(测试/配置驱动模式)。 + */ private ProviderSnapshot loadProviderOrThrow(String providerId) { if (providerRepository == null) { return loadProviderFromPropertiesOrThrow(providerId); @@ -368,6 +451,7 @@ private ProviderSnapshot loadProviderOrThrow(String providerId) { return new ProviderSnapshot( entity.getId(), entity.getBaseUrl(), + // API Key 在 DB 中以 nonce + ciphertext 分存,需解密后使用 encryptionService.decrypt(entity.getApiKeyNonce(), entity.getApiKeyCiphertext()), entity.getModel(), entity.getEmbeddingModel(), @@ -377,6 +461,10 @@ private ProviderSnapshot loadProviderOrThrow(String providerId) { ); } + /** + * 从配置文件加载 Provider 配置。 + * supportsEmbedding 自动推导:显式开启或配置了 embeddingModel 即视为支持。 + */ private ProviderSnapshot loadProviderFromPropertiesOrThrow(String providerId) { ProviderConfig config = properties.getProviders().get(providerId); if (config == null) { @@ -397,10 +485,15 @@ private ProviderSnapshot loadProviderFromPropertiesOrThrow(String providerId) { ); } + // ---- 工具方法 ---- + private boolean isBlank(String value) { return value == null || value.isBlank(); } + /** + * 解析 Embedding 维度:配置值 > 0 则用配置值,否则用全局默认 dimensions。 + */ private Integer resolveEmbeddingDimensions(Integer configuredDimensions) { if (configuredDimensions != null && configuredDimensions > 0) { return configuredDimensions; @@ -408,6 +501,10 @@ private Integer resolveEmbeddingDimensions(Integer configuredDimensions) { return properties.getEmbeddingDimensions(); } + /** + * 启发式判断用户是否误将聊天模型名填入了 Embedding 模型配置。 + * 常见聊天模型名以 glm- / deepseek / qwen 等开头,Embedding 模型名不会有这些前缀。 + */ private boolean looksLikeChatModel(String model) { String lower = model.toLowerCase(); return lower.startsWith("glm-") @@ -418,6 +515,8 @@ private boolean looksLikeChatModel(String model) { || lower.startsWith("ernie"); } + // ---- 内部数据载体 ---- + private record ProviderSnapshot( String id, String baseUrl, From 8579c58ce446c366a618f77886e4d5263c412146 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E4=B8=B0=E4=BD=B3?= Date: Wed, 10 Jun 2026 14:15:05 +0800 Subject: [PATCH 04/11] =?UTF-8?q?docs(ai):=20=E5=AE=8C=E5=96=84=20Structur?= =?UTF-8?q?edOutputInvoker=20=E7=B1=BB=E6=B3=A8=E9=87=8A=E4=B8=8E=E5=B8=B8?= =?UTF-8?q?=E9=87=8F=E5=88=86=E7=BB=84?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 扩充类注释,说明 LLM JSON 输出的常见问题及处理策略 - 添加常量区域分隔注释,提升代码可读性 - 简化常量注释,聚焦于\"为什么\"而非\"是什么\" --- .../common/ai/StructuredOutputInvoker.java | 67 ++++++++++++++++++- 1 file changed, 64 insertions(+), 3 deletions(-) diff --git a/app/src/main/java/interview/guide/common/ai/StructuredOutputInvoker.java b/app/src/main/java/interview/guide/common/ai/StructuredOutputInvoker.java index 6a5a466e..030159ff 100644 --- a/app/src/main/java/interview/guide/common/ai/StructuredOutputInvoker.java +++ b/app/src/main/java/interview/guide/common/ai/StructuredOutputInvoker.java @@ -14,11 +14,20 @@ import java.util.regex.Pattern; /** - * 统一封装结构化输出调用与重试策略。 + * 结构化输出调用与重试的统一封装。 + *

+ * LLM 生成 JSON 格式输出时,常见问题: + * - 输出 Markdown 代码块包裹的 JSON(```json ... ```) + * - JSON 字符串值内引号未转义 + * - 附带解释文字 + * 此类通过重试 + 本地修复 + 严格指令组合处理上述问题。 */ @Component public class StructuredOutputInvoker { + // === 常量区 === + + // 重试时追加的严格 JSON 指令——提醒模型不要输出 Markdown 包裹、额外文字或未转义引号 private static final String STRICT_JSON_INSTRUCTION = """ 请仅返回可被 JSON 解析器直接解析的 JSON 对象,并严格满足字段结构要求: 1) 不要输出 Markdown 代码块(如 ```json)。 @@ -26,36 +35,51 @@ public class StructuredOutputInvoker { 3) 所有字符串内引号必须正确转义。 """; + // invocations = 一次 invoke() 整体成功/失败,attempts = 每次 LLM 请求(含重试),latency = 总耗时 private static final String METRIC_INVOCATIONS = "app.ai.structured_output.invocations"; private static final String METRIC_ATTEMPTS = "app.ai.structured_output.attempts"; private static final String METRIC_LATENCY = "app.ai.structured_output.latency"; private static final String STATUS_SUCCESS = "success"; private static final String STATUS_FAILURE = "failure"; + // 以下用于 context tag 归一化,适配 Prometheus/Micrometer tag 命名规范 private static final int MAX_CONTEXT_TAG_LENGTH = 48; private static final Pattern NON_ALNUM_PATTERN = Pattern.compile("[^a-z0-9_]+"); private static final Pattern MULTI_UNDERSCORE = Pattern.compile("_+"); + // === 配置字段(从 StructuredOutputProperties 注入) === + private final int maxAttempts; private final boolean includeLastErrorInRetryPrompt; private final boolean retryUseRepairPrompt; private final boolean retryAppendStrictJsonInstruction; private final int errorMessageMaxLength; private final boolean metricsEnabled; + // MeterRegistry 可选——无 Micrometer 依赖时不报错 private final MeterRegistry meterRegistry; public StructuredOutputInvoker( StructuredOutputProperties properties, @Autowired(required = false) MeterRegistry meterRegistry ) { + // 最少重试 1 次(至少一次调用),避免配置意外设为 0 或负数 this.maxAttempts = Math.max(1, properties.getStructuredMaxAttempts()); this.includeLastErrorInRetryPrompt = properties.isStructuredIncludeLastError(); this.retryUseRepairPrompt = properties.isStructuredRetryUseRepairPrompt(); this.retryAppendStrictJsonInstruction = properties.isStructuredRetryAppendStrictJsonInstruction(); + // 错误消息截断最少保留 20 字符,避免太短看不出问题 this.errorMessageMaxLength = Math.max(20, properties.getStructuredErrorMessageMaxLength()); this.metricsEnabled = properties.isStructuredMetricsEnabled(); this.meterRegistry = meterRegistry; } + /** + * 调用 LLM 并解析结构化输出,失败时按配置重试。 + *

+ * 重试策略三层递进: + * 1. 本地 repair 修复常见 JSON 格式问题(未转义引号) + * 2. 重试时追加严格 JSON 指令 + 上次失败原因 + * 3. 达到 maxAttempts 后抛出 BusinessException + */ public T invoke( ChatClient chatClient, String systemPromptWithFormat, @@ -68,10 +92,12 @@ public T invoke( ) { long startNanos = System.nanoTime(); String contextTag = normalizeContextTag(logContext); + // 追加防注入指令:防止用户输入通过 system prompt 劫持 LLM 行为 String securedSystemPrompt = systemPromptWithFormat + PromptSecurityConstants.ANTI_INJECTION_INSTRUCTION; Exception lastError = null; for (int attempt = 1; attempt <= maxAttempts; attempt++) { + // 首次用原始 prompt,重试用增强 prompt(含上次错误信息和严格 JSON 指令) String attemptSystemPrompt = attempt == 1 ? securedSystemPrompt : buildRetrySystemPrompt(securedSystemPrompt, lastError); @@ -81,6 +107,7 @@ public T invoke( .user(userPrompt) .call() .content(); + // 先尝试本地修复(未转义引号),修复不成才抛异常触发重试 T result = convertWithRepair(content, outputConverter, logContext, log); recordAttempt(contextTag, STATUS_SUCCESS); recordInvocation(contextTag, STATUS_SUCCESS, startNanos); @@ -98,6 +125,7 @@ public T invoke( } } + // 所有重试耗尽,记录失败指标并抛出业务异常 recordInvocation(contextTag, STATUS_FAILURE, startNanos); throw new BusinessException( errorCode, @@ -105,6 +133,10 @@ public T invoke( ); } + /** + * 解析 JSON 输出,首次失败时尝试本地修复未转义引号后再解析。 + * 本地修复比重试 LLM 成本低得多,且有很高命中率——这是 LLM 输出 JSON 最常见的格式缺陷。 + */ private T convertWithRepair( String content, BeanOutputConverter outputConverter, @@ -128,13 +160,18 @@ private T convertWithRepair( } } + /** + * 修复 JSON 字符串值内未转义的引号(双引号前面缺少反斜杠)。 + * 这是 LLM 输出 JSON 最频繁的错误模式:如 {"name":他说"你好"} -> {"name":他说\"你好\"} + * 使用状态机逐字符扫描,仅在确信是字符串终结符时才保留原始 "。 + */ private String repairUnescapedQuotesInJsonStrings(String content) { if (content == null || content.isBlank()) { return content; } StringBuilder repaired = new StringBuilder(content.length() + 16); - boolean inString = false; - boolean escaping = false; + boolean inString = false; // 当前是否在 JSON 字符串值内部 + boolean escaping = false; // 前一个字符是否为反斜杠(转义模式) for (int i = 0; i < content.length(); i++) { char ch = content.charAt(i); if (!inString) { @@ -156,6 +193,7 @@ private String repairUnescapedQuotesInJsonStrings(String content) { continue; } if (ch == '"') { + // 根据后续字符判断这是字符串终结符还是字符串内部的双引号 if (isLikelyJsonStringTerminator(content, i + 1)) { inString = false; repaired.append(ch); @@ -169,6 +207,11 @@ private String repairUnescapedQuotesInJsonStrings(String content) { return repaired.toString(); } + /** + * 启发式判断当前位置的双引号是否可能是字符串的终结符。 + * 跳过空白后,如果后面的字符是 ,}]: 之一,则很可能是终结符; + * 如果后面紧跟其他字符(如中文),则很可能是字符串内部的引号。 + */ private boolean isLikelyJsonStringTerminator(String content, int start) { for (int i = start; i < content.length(); i++) { char next = content.charAt(i); @@ -180,6 +223,10 @@ private boolean isLikelyJsonStringTerminator(String content, int start) { return true; } + /** + * 构建重试 prompt:保留原 system prompt,追加严格 JSON 指令和(可选的)上次失败原因。 + * 由配置控制是否启用:retryUseRepairPrompt、retryAppendStrictJsonInstruction、includeLastErrorInRetryPrompt。 + */ private String buildRetrySystemPrompt(String systemPromptWithFormat, Exception lastError) { if (!retryUseRepairPrompt) { return systemPromptWithFormat; @@ -200,6 +247,9 @@ private String buildRetrySystemPrompt(String systemPromptWithFormat, Exception l return prompt.toString(); } + /** + * 清洗错误消息:去换行、截断,避免重试 prompt 中的错误消息太长稀释有效指令。 + */ private String sanitizeErrorMessage(String message) { String oneLine = message.replace('\n', ' ').replace('\r', ' ').trim(); if (oneLine.length() > errorMessageMaxLength) { @@ -208,6 +258,8 @@ private String sanitizeErrorMessage(String message) { return oneLine; } + // === 指标记录 === + private void recordAttempt(String contextTag, String status) { if (!isMetricsAvailable()) { return; @@ -218,12 +270,17 @@ private void recordAttempt(String contextTag, String status) { ).increment(); } + /** + * 记录一次调用。 + */ private void recordInvocation(String contextTag, String status, long startNanos) { if (!isMetricsAvailable()) { return; } Tags tags = Tags.of("context", contextTag, "status", status); + // 调用次数 meterRegistry.counter(METRIC_INVOCATIONS, tags).increment(); + // 调用耗时 meterRegistry.timer(METRIC_LATENCY, tags) .record(System.nanoTime() - startNanos, java.util.concurrent.TimeUnit.NANOSECONDS); } @@ -232,6 +289,10 @@ private boolean isMetricsAvailable() { return metricsEnabled && meterRegistry != null; } + /** + * 将上下文标签归一化为 Prometheus tag 可接受的格式: + * 小写 + 下划线 + 最多 48 字符。 + */ private String normalizeContextTag(String raw) { String source = (raw == null || raw.isBlank()) ? "unknown" : raw; String normalized = source.toLowerCase(Locale.ROOT).trim().replace(' ', '_'); From 03a8c7c08f57d0636c344627a0394de32a470aff Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E4=B8=B0=E4=BD=B3?= Date: Wed, 10 Jun 2026 15:12:10 +0800 Subject: [PATCH 05/11] =?UTF-8?q?refactor(aspect):=20=E6=8F=90=E5=8F=96?= =?UTF-8?q?=E9=87=8D=E5=A4=8D=E7=9A=84=20Lua=20=E8=84=9A=E6=9C=AC=E6=89=A7?= =?UTF-8?q?=E8=A1=8C=E9=80=BB=E8=BE=91=E5=B9=B6=E6=B8=85=E7=90=86=E5=BC=82?= =?UTF-8?q?=E5=B8=B8=E5=A3=B0=E6=98=8E?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 将两处 `rScript.evalSha()` 调用抽取为 `executeRateLimitScript()` 方法 - 添加 `@Nullable` 注解标记新方法可空返回值 - 移除 `handleRateLimitExceeded` 方法多余的 `throws Throwable` --- .../guide/common/aspect/RateLimitAspect.java | 36 +++++++++---------- 1 file changed, 18 insertions(+), 18 deletions(-) diff --git a/app/src/main/java/interview/guide/common/aspect/RateLimitAspect.java b/app/src/main/java/interview/guide/common/aspect/RateLimitAspect.java index 3cf2abbd..cdaf8ac4 100644 --- a/app/src/main/java/interview/guide/common/aspect/RateLimitAspect.java +++ b/app/src/main/java/interview/guide/common/aspect/RateLimitAspect.java @@ -9,6 +9,7 @@ import org.aspectj.lang.annotation.Around; import org.aspectj.lang.annotation.Aspect; import org.aspectj.lang.reflect.MethodSignature; +import org.jspecify.annotations.Nullable; import org.redisson.api.RScript; import org.redisson.api.RedissonClient; import org.redisson.client.codec.StringCodec; @@ -100,31 +101,31 @@ private Long executeRateLimitScript(String key, long nowMs, String requestId, lo }; try { - Object resultObj = rScript.evalSha( - RScript.Mode.READ_WRITE, - luaScriptSha, - RScript.ReturnType.VALUE, - keysList, - args - ); - return convertToLong(resultObj); + return executeRateLimitScript(keysList, args); } catch (org.redisson.client.RedisException e) { // Redis 重启后脚本缓存丢失,重新加载并重试 if (e.getMessage() != null && e.getMessage().contains("NOSCRIPT")) { loadScript(); - Object resultObj = rScript.evalSha( - RScript.Mode.READ_WRITE, - luaScriptSha, - RScript.ReturnType.VALUE, - keysList, - args - ); - return convertToLong(resultObj); + return executeRateLimitScript(keysList, args); } throw e; } } + /** + * 执行限流 Lua 脚本 + */ + private @Nullable Long executeRateLimitScript(List keysList, Object[] args) { + Object resultObj = rScript.evalSha( + RScript.Mode.READ_WRITE, + luaScriptSha, + RScript.ReturnType.VALUE, + keysList, + args + ); + return convertToLong(resultObj); + } + private long calculateIntervalMs(long interval, RateLimit.TimeUnit unit) { return switch (unit) { case MILLISECONDS -> interval; @@ -162,8 +163,7 @@ private String generateKey(String className, String methodName, RateLimit.Dimens }; } - private Object handleRateLimitExceeded(ProceedingJoinPoint joinPoint, RateLimit rateLimit, String key) - throws Throwable { + private Object handleRateLimitExceeded(ProceedingJoinPoint joinPoint, RateLimit rateLimit, String key) { String methodName = joinPoint.getSignature().getName(); if (rateLimit.fallback() != null && !rateLimit.fallback().isEmpty()) { From 815d924b3423ac8971faa7e46fac3561db429c18 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E4=B8=B0=E4=BD=B3?= Date: Wed, 10 Jun 2026 15:58:49 +0800 Subject: [PATCH 06/11] =?UTF-8?q?docs(async):=20=E4=B8=BA=20AbstractStream?= =?UTF-8?q?Consumer=20=E8=A1=A5=E5=85=85=E5=AE=8C=E6=95=B4=E7=9A=84?= =?UTF-8?q?=E8=AE=BE=E8=AE=A1=E5=86=B3=E7=AD=96=20JavaDoc?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 类级别注释说明选择 Redis Stream + 模板方法模式的三条理由 - 为 init/shutdown 方法说明线程安全设计(AtomicBoolean 与关闭顺序) - 为 processMessage 注释重试策略选择与 ACK 时机考量 - 为 truncateError 说明 500 字符截断的原因 - 为 ackMessage 解释吞异常而非传播的设计取舍 - 为 redisService() 访问器保留未来横切扩展的可能性 --- .../common/async/AbstractStreamConsumer.java | 70 +++++++++++++++++++ 1 file changed, 70 insertions(+) diff --git a/app/src/main/java/interview/guide/common/async/AbstractStreamConsumer.java b/app/src/main/java/interview/guide/common/async/AbstractStreamConsumer.java index b49d6807..57d5773e 100644 --- a/app/src/main/java/interview/guide/common/async/AbstractStreamConsumer.java +++ b/app/src/main/java/interview/guide/common/async/AbstractStreamConsumer.java @@ -15,21 +15,49 @@ import java.util.concurrent.TimeUnit; import java.util.concurrent.atomic.AtomicBoolean; +/** + * Redis Stream 消费者模板 —— 保证所有异步消费者共享一致的消费生命周期。 + *

+ * 选择 Redis Stream + 模板方法模式而非简单队列或函数式接口,原因有三: + *

    + *
  1. Consumer Group 提供显式 ACK 机制,实现至少一次投递语义,消息处理失败后不会丢失;
  2. + *
  3. 同一 Group 内的多个消费者可水平扩展,Stream 自动做负载均衡;
  4. + *
  5. 模板强制子类只关心业务钩子(解析、处理、状态标记), + * 基础设施(Consumer Group 创建、轮询循环、ACK 与重试)由本类统一管理,不可覆写。
  6. + *
+ *

+ * 工作流程:初始化 → 创建/确认 Consumer Group → 阻塞轮询 → 逐条解析 → 回调子类业务 → ACK → 失败时按 {@link AsyncTaskStreamConstants#MAX_RETRY_COUNT} 重试。 + * + * @param 消息荷载类型,子类在 {@link #parsePayload} 中反序列化 + */ @Slf4j public abstract class AbstractStreamConsumer { private final RedisService redisService; + // 使用 AtomicBoolean 而非 volatile,因为 init(主线程)和 shutdown(容器关闭线程)不在同一线程, + // 需要保证 running 的更新对消费者线程立即可见且不会出现指令重排导致的"停不掉" private final AtomicBoolean running = new AtomicBoolean(false); private ExecutorService executorService; private String consumerName; + /** + * @param redisService 基础设施注入,子类不应直接持有 RedisService —— 如需读取辅助状态, + * 请使用受保护的 {@link #redisService()} 访问器 + */ protected AbstractStreamConsumer(RedisService redisService) { this.redisService = redisService; } + /** + * Spring 容器完成 Bean 组装后立即启动消费线程,不等待第一个消息到达。 + * 如果等懒加载则可能在启动高峰期间积压消息,失去 Stream 削峰填谷的优势。 + */ @PostConstruct public void init() { + // Consumer Name 添加随机后缀:同一服务的多实例部署时实例名相同,但 Redis Consumer Group 中每个消费者须唯一 this.consumerName = consumerPrefix() + UUID.randomUUID().toString().substring(0, 8); + // 单线程 + 容量为 0 的队列:保证单消费者内消息严格顺序处理;AbortPolicy 使线程池在异常状态时快速失败, + // 避免消费者静默消失(整个生命周期仅提交 1 个任务,拒绝意味着无法恢复) this.executorService = new ThreadPoolExecutor( 1, 1, @@ -49,6 +77,11 @@ public void init() { log.info("{} consumer started: consumerName={}", taskDisplayName(), consumerName); } + /** + * 容器关闭时先置 running 为 false 让轮询循环自然退出,再关闭线程池。 + * 顺序不可颠倒:先 shutdown 线程池会打断正在处理的消息,导致该消息既未 ACK 也未标记失败, + * 重启后 XREADGROUP 重投递时无从判断是"未处理"还是"已处理但丢 ACK"。 + */ @PreDestroy public void shutdown() { running.set(false); @@ -58,6 +91,10 @@ public void shutdown() { log.info("{} consumer stopped: consumerName={}", taskDisplayName(), consumerName); } + /** + * 启动第一步:确保 Consumer Group 存在(允许抛异常,Group 可能已由其他消费者创建——那是幂等且预期的)。 + * 即使创建失败也继续进入轮询,因为 Group 可能已在上一轮重启前创建好。 + */ private void startConsumer() { try { redisService.createStreamGroup(streamKey(), groupName()); @@ -69,6 +106,10 @@ private void startConsumer() { consumeLoop(); } + /** + * 长连接轮询。底层 {@link RedisService#streamConsumeMessages} 使用 XREADGROUP 阻塞读取, + * 并非真正的忙等。唯一非 running 的出口是 {@link #shutdown()} 置标志位,或 JVM 向守护线程发中断。 + */ private void consumeLoop() { while (running.get()) { try { @@ -81,6 +122,7 @@ private void consumeLoop() { this::processMessage ); } catch (Exception e) { + // 守护线程在 JVM 关闭时收到中断信号而不是 running=false —— 识别后优雅退出,避免日志污染 if (Thread.currentThread().isInterrupted()) { log.info("Consumer thread interrupted"); break; @@ -90,8 +132,16 @@ private void consumeLoop() { } } + /** + * 单条消息的处理管线:解析 → 前置标记 → 业务处理 → 完成标记 → ACK。 + * 失败时依据重试次数决定是重新投递还是进入死信(标记 FAILED + ACK 移除)。 + * ACK 时机:无论成功或最终失败都立即 ACK(死信策略是"移除后外部记录",而非"保留在 Stream 中人工介入"), + * 避免未 ACK 消息堆积阻塞同 Group 中其他消费者的正常消费。 + */ private void processMessage(StreamMessageId messageId, Map data) { T payload = parsePayload(messageId, data); + // 解析失败(payload == null)说明消息体损坏或无法识别的格式——这类 Poison Pill 必须 ACK 移除, + // 否则会卡在 Pending List 中永无止境地重投递 if (payload == null) { ackMessage(messageId); return; @@ -109,6 +159,8 @@ private void processMessage(StreamMessageId messageId, Map data) log.info("{} task completed: {}", taskDisplayName(), payloadIdentifier(payload)); } catch (Exception e) { log.error("{} task failed: {}", taskDisplayName(), payloadIdentifier(payload), e); + // 自主控制重试计数而非使用 Redis XCLAIM/Autoclaim,因为 Stream 原生的超时重投递时延不可控, + // 无法保证在合理时间内完成指定次数重试 if (retryCount < AsyncTaskStreamConstants.MAX_RETRY_COUNT) { retryMessage(payload, retryCount + 1); } else { @@ -120,6 +172,9 @@ private void processMessage(StreamMessageId messageId, Map data) } } + /** + * 从 Stream 消息体解析重试次数。旧版本生产者的消息可能不含此字段,兜底返回 0 表示首次处理。 + */ protected int parseRetryCount(Map data) { try { return Integer.parseInt(data.getOrDefault(AsyncTaskStreamConstants.FIELD_RETRY_COUNT, "0")); @@ -128,6 +183,10 @@ protected int parseRetryCount(Map data) { } } + /** + * 截断错误消息至 500 字符。各模块的失败标记({@link #markFailed(Object, String)})通常写入数据库, + * 而 error 字段长度有限(多为 VARCHAR(500)),超出则导致持久化失败——本应是错误记录的日志反而引入新错误。 + */ protected String truncateError(String error) { if (error == null) { return null; @@ -135,6 +194,11 @@ protected String truncateError(String error) { return error.length() > 500 ? error.substring(0, 500) : error; } + /** + * ACK 失败时吞掉异常而不是传播。因为即使 ACK 失败,消息最终会被 Stream 重新投递给另一个消费者, + * 同一消息重入时子类的业务处理({@link #processBusiness})应保证幂等。 + * 因 ACK 异常而导致消费者线程崩溃,代价远大于偶发的重复处理。 + */ private void ackMessage(StreamMessageId messageId) { try { redisService.streamAck(streamKey(), groupName(), messageId); @@ -143,10 +207,16 @@ private void ackMessage(StreamMessageId messageId) { } } + /** + * 子类若需读取 Redis 中与 Stream 消费相关的辅助状态(如检查对应实体是否存在),可通过此访问器获取。 + * 设计为 protected 方法而非直接 protected 字段,保留将来在访问点上添加横切逻辑(如指标收集)的可能。 + */ protected RedisService redisService() { return redisService; } + // ==================== 子类必须实现的抽象方法 ==================== + protected abstract String taskDisplayName(); protected abstract String streamKey(); From 7350e2ddd0cf72dad9c7da627b067522296d0d86 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E4=B8=B0=E4=BD=B3?= Date: Wed, 10 Jun 2026 16:32:45 +0800 Subject: [PATCH 07/11] =?UTF-8?q?docs(config):=20=E6=9B=B4=E6=96=B0=20LlmE?= =?UTF-8?q?mbeddingConfig=20=E7=B1=BB=E7=9A=84=20JavaDoc=20=E6=B3=A8?= =?UTF-8?q?=E9=87=8A?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 添加详细的类级别注释说明 LlmProviderRegistry 包装为 EmbeddingModel Bean 的设计原理 - 解释使用委托层而非直接注入具体 Provider 的两个核心原因 - 说明运行时 Provider 切换与启动时 Bean 固化的架构考量 - 添加方法级别的注释解释 embed(Document) 方法重写的必要性 - 描述 Spring AI 中不同方法调用路径的性能优化差异 --- .../common/config/LlmEmbeddingConfig.java | 20 +++++++++++++++++++ 1 file changed, 20 insertions(+) diff --git a/app/src/main/java/interview/guide/common/config/LlmEmbeddingConfig.java b/app/src/main/java/interview/guide/common/config/LlmEmbeddingConfig.java index e31d6268..1be6fb98 100644 --- a/app/src/main/java/interview/guide/common/config/LlmEmbeddingConfig.java +++ b/app/src/main/java/interview/guide/common/config/LlmEmbeddingConfig.java @@ -9,10 +9,30 @@ import org.springframework.context.annotation.Bean; import org.springframework.context.annotation.Configuration; +/** + * 将 {@link LlmProviderRegistry} 包装为 Spring AI 的 {@link EmbeddingModel} Bean。 + *

+ * 之所以不直接注入或暴露某个 Provider 的 EmbeddingModel,而是通过一个薄委托层中转,是因为: + *

    + *
  1. {@link LlmProviderRegistry} 负责在运行时决定使用哪个 Provider(按配置路由、兜底、缓存), + * 而 Spring 的 {@code @Bean} 在启动时即固化——如果直接注入一个具体的 EmbeddingModel, + * 切换 Provider 必须重启应用;
  2. + *
  3. 所有依赖 {@code EmbeddingModel} 的组件(如向量化服务)只需注入这一个 Bean,感知不到其背后的 Provider 切换, + * 符合依赖倒置原则。
  4. + *
+ * 本 Bean 本身是无状态的纯委托,不持有 Provider 实例,所有调用最终由 Registry 按当前配置路由。 + */ @Configuration @Slf4j public class LlmEmbeddingConfig { + /** + * 注册一个委托 EmbeddingModel Bean,所有方法转发至 Registry 的默认 Provider。 + *

+ * 显式重写 {@code embed(Document)} 是因为 Spring AI 在某些 Provider 实现中为 {@code embed(Document)}
+ * 提供了独立的优化路径(如批量编码、缓存),与 {@code call(EmbeddingRequest)} 走不同内部逻辑—— + * 仅委托 {@code call()} 会导致这些优化丢失。 + */ @Bean public EmbeddingModel embeddingModel(LlmProviderRegistry registry) { log.info("EmbeddingModel bean initialized as registry delegate"); From 7990dcb0e56845fbb6c060bb60ca6d4bc4ecb771 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E4=B8=B0=E4=BD=B3?= Date: Thu, 11 Jun 2026 14:27:42 +0800 Subject: [PATCH 08/11] =?UTF-8?q?docs(evaluation):=20=E6=9B=B4=E6=96=B0?= =?UTF-8?q?=E9=9D=A2=E8=AF=95=E8=AF=84=E4=BC=B0=E6=A8=A1=E5=9D=97=E6=96=87?= =?UTF-8?q?=E6=A1=A3=E6=B3=A8=E9=87=8A?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 为 EvaluationReport 添加详细类注释说明其用途和字段含义 - 为 QaRecord 添加完整文档注释包括参数说明和使用场景 - 为 UnifiedEvaluationService 添加架构设计说明和分批评估流程解释 - 为各个内部 record 类添加字段级别的详细注释 - 为核心方法添加处理流程和设计考虑的说明文档 --- .../common/evaluation/EvaluationReport.java | 45 ++++- .../guide/common/evaluation/QaRecord.java | 12 +- .../evaluation/UnifiedEvaluationService.java | 154 ++++++++++++++++-- 3 files changed, 197 insertions(+), 14 deletions(-) diff --git a/app/src/main/java/interview/guide/common/evaluation/EvaluationReport.java b/app/src/main/java/interview/guide/common/evaluation/EvaluationReport.java index fc0ab7f5..cf83580e 100644 --- a/app/src/main/java/interview/guide/common/evaluation/EvaluationReport.java +++ b/app/src/main/java/interview/guide/common/evaluation/EvaluationReport.java @@ -3,25 +3,58 @@ import java.util.List; /** - * 通用面试评估报告(文字面试和语音面试共用) + * 通用面试评估报告(文字面试和语音面试共用)。 + *

+ * 由 AI 模型根据候选人的回答生成,包含整体评分、各维度得分、逐题评价和参考答案。 + * 用于前端展示面试结果和 PDF 报告导出。 */ public record EvaluationReport( + /* 面试会话 ID,关联 InterviewSessionEntity 或 VoiceInterviewSessionEntity */ String sessionId, + /* 本次面试的总题数 */ int totalQuestions, + /* 综合得分(0-100),由 AI 根据各题表现加权计算 */ int overallScore, + /* 各评估维度得分(如:技术能力、项目经验、沟通表达等) */ List categoryScores, + /* 逐题评价详情,包含候选人回答、得分和反馈 */ List questionDetails, + /* 整体评价总结,概括候选人的综合表现 */ String overallFeedback, + /* 候选人优势列表(如:"基础扎实"、"表达清晰") */ List strengths, + /* 待改进项列表(如:"缺乏分布式经验"、"项目描述过于简略") */ List improvements, + /* 参考答案列表,供候选人对照学习 */ List referenceAnswers ) { + /** + * 评估维度得分。 + *

+ * 例如:技术能力 85 分(3 题)、项目经验 78 分(2 题)、沟通表达 90 分(1 题) + * + * @param category 维度名称(如"技术能力"、"项目经验"、"沟通表达") + * @param score 该维度的得分(0-100) + * @param questionCount 该维度下的题目数量 + */ public record CategoryScore( String category, int score, int questionCount ) {} + /** + * 单题评价详情。 + *

+ * 记录每道题的问题内容、候选人回答、AI 评分和反馈意见。 + * + * @param questionIndex 题目序号(从 1 开始) + * @param question 面试问题内容 + * @param category 题目所属维度(如"技术能力") + * @param userAnswer 候选人的回答内容 + * @param score 该题得分(0-100) + * @param feedback AI 对该回答的具体反馈 + */ public record QuestionEvaluation( int questionIndex, String question, @@ -31,6 +64,16 @@ public record QuestionEvaluation( String feedback ) {} + /** + * 参考答案。 + *

+ * 为每道题提供标准答案和关键要点,帮助候选人了解理想回答应包含的内容。 + * + * @param questionIndex 题目序号(从 1 开始) + * @param question 面试问题内容 + * @param referenceAnswer 参考答案全文 + * @param keyPoints 关键要点列表(如:"提到索引优化"、"解释事务隔离级别") + */ public record ReferenceAnswer( int questionIndex, String question, diff --git a/app/src/main/java/interview/guide/common/evaluation/QaRecord.java b/app/src/main/java/interview/guide/common/evaluation/QaRecord.java index 9183d893..67983143 100644 --- a/app/src/main/java/interview/guide/common/evaluation/QaRecord.java +++ b/app/src/main/java/interview/guide/common/evaluation/QaRecord.java @@ -1,11 +1,19 @@ package interview.guide.common.evaluation; /** - * 通用面试问答记录(文字面试和语音面试共用) + * 单题问答记录,用于将面试问答数据传递给 AI 评估模型。 + *

+ * 封装一道面试题的完整上下文:题目序号、问题内容、所属维度和候选人回答。 + * 作为 {@link EvaluationReport} 评估流程的输入数据载体。 + * + * @param questionIndex 题目序号(从 1 开始),用于保持题目顺序 + * @param question 面试问题内容 + * @param category 题目所属评估维度(如"技术能力"、"项目经验"、"沟通表达") + * @param userAnswer 候选人的回答内容;null 表示候选人未回答该题 */ public record QaRecord( int questionIndex, String question, String category, - String userAnswer // null 表示未回答 + String userAnswer ) {} diff --git a/app/src/main/java/interview/guide/common/evaluation/UnifiedEvaluationService.java b/app/src/main/java/interview/guide/common/evaluation/UnifiedEvaluationService.java index f145190c..0abc07f4 100644 --- a/app/src/main/java/interview/guide/common/evaluation/UnifiedEvaluationService.java +++ b/app/src/main/java/interview/guide/common/evaluation/UnifiedEvaluationService.java @@ -25,13 +25,27 @@ import java.util.stream.Collectors; /** - * 统一面试评估服务 - * 文字面试和语音面试共用的评估逻辑:分批评估 + 结构化输出 + 二次汇总 + 降级兜底 + * 文字面试和语音面试共用的评估服务。 + *

+ * 评估采用分批评估 → 二次汇总的两阶段设计: + *

    + *
  1. 分批评估:将问答记录按 {@code batchSize} 分组,每组调用 LLM 生成结构化评估。分批是因为 LLM 的 + * 上下文窗口有限,一次塞入数十题会导致注意力分散、早期信息被遗忘(lost-in-the-middle),降低评分准确性;
  2. + *
  3. 二次汇总:将各批次的评估结果(按题目维度)和批次级总评(按批次维度)一并喂给 LLM 重新整理, + * 输出统一的综合评价。避免"各批次各说各话"导致的融合感差。
  4. + *
+ * 每次 LLM 调用均使用 {@link StructuredOutputInvoker} 自动重试 + 结构化输出, + * 确保返回格式稳定可解析。任一批次失败不影响其他批次——有降级兜底。 + *

+ * 之所以放在 {@code common.evaluation} 而非某个业务模块,是因为 {@code interview} 和 + * {@code voiceinterview} 两个模块都需要评估,避免重复实现。 */ @Service public class UnifiedEvaluationService { private static final Logger log = LoggerFactory.getLogger(UnifiedEvaluationService.class); + // 参考基线文本最长 6000 字符(约 3000~4000 tokens),与简历截断策略配合, + // 确保两路上下文合计不超过单批次 LLM 输入的上限 private static final int MAX_REFERENCE_CONTEXT_CHARS = 6000; private final PromptTemplate systemPromptTemplate; @@ -44,35 +58,63 @@ public class UnifiedEvaluationService { private final int evaluationBatchSize; private final ResourceLoader resourceLoader; - // 批次评估结果 + // 以下四个内部 record 仅在本服务内部传递数据,不暴露到外部 API: + // BatchReportDTO 对应 LLM 单批次输出的结构化 schema + // QuestionEvalDTO 是单题评估的扁平载体,按 questionIndex 对齐 + // BatchResult 封装某批次的索引范围+报告,合并阶段按索引定位 + // SummaryDTO 是二次汇总 LLM 调用的输出 schema,只包含汇总级别的字段 private record BatchReportDTO( + // 本批次整体评分(0-100) int overallScore, + // 本批次整体评语 String overallFeedback, + // 候选人表现优点列表 List strengths, + // 候选人待改进点列表 List improvements, + // 本批次每道题的逐题评估结果 List questionEvaluations ) {} private record QuestionEvalDTO( + // 题目在完整问答列表中的全局索引(0-based),用于跨批次合并时对齐 int questionIndex, + // 单题评分(0-100) int score, + // 单题评估反馈 String feedback, + // 参考答案 String referenceAnswer, + // 参考答案的关键要点 List keyPoints ) {} private record BatchResult( + // 本批次在完整问答列表中的起始索引(含) int startIndex, + // 本批次在完整问答列表中的结束索引(不含) int endIndex, + // 本批次的评估报告,评估失败时为 null BatchReportDTO report ) {} private record SummaryDTO( + // 二次汇总后的综合评语,为空时降级使用批次聚合结果 String overallFeedback, + // 汇总后的优点列表 List strengths, + // 汇总后的改进点列表 List improvements ) {} + /** + * @param structuredOutputInvoker 结构化输出重试封装——两次 LLM 调用(批次评估 + 二次汇总)都经过它, + * 确保反序列化失败时自动重试,不因一次格式异常中断整个评估流程 + * @param resourceLoader 从 classpath 加载 Prompt 模板文件(.st 格式), + * 路径由配置 {@code app.evaluation.prompts.*} 指定 + * @param evaluationProperties 批大小可调:调小降低每次 LLM 调用丢给模型的压力但总调用次数增加, + * 调大减少调用次数但增加上下文丢失风险——默认值需按实际模型上下文长度调整 + */ public UnifiedEvaluationService( StructuredOutputInvoker structuredOutputInvoker, ResourceLoader resourceLoader, @@ -85,17 +127,12 @@ public UnifiedEvaluationService( this.summarySystemPromptTemplate = new PromptTemplate(loadPrompt(evaluationProperties.getSummarySystemPromptPath())); this.summaryUserPromptTemplate = new PromptTemplate(loadPrompt(evaluationProperties.getSummaryUserPromptPath())); this.summaryOutputConverter = new BeanOutputConverter<>(SummaryDTO.class); + // 批大小 <= 0 则退化为一次评一题——安全性兜底,撞墙场景(配置错误)下不至于抛出异常 this.evaluationBatchSize = Math.max(1, evaluationProperties.getBatchSize()); } /** - * 评估面试问答(文字和语音通用) - * - * @param chatClient LLM 客户端 - * @param sessionId 会话ID(用于日志) - * @param qaRecords 问答记录列表 - * @param resumeText 简历摘要(可选,可为 null) - * @return 评估报告 + * 评估入口(无参考基线版本)。 */ public EvaluationReport evaluate(ChatClient chatClient, String sessionId, @@ -104,6 +141,22 @@ public EvaluationReport evaluate(ChatClient chatClient, return evaluate(chatClient, sessionId, qaRecords, resumeText, null); } + /** + * 评估入口(含参考基线)。 + *

+ * 处理流程: + *

    + *
  1. 截断简历和参考基线至合理长度,避免单次 LLM 调用超过上下文上限(截断在前,分批在后);
  2. + *
  3. 按 {@link #evaluationBatchSize} 分批调用 LLM,每批输出结构化的评分子集;
  4. + *
  5. 合并所有批次的评估结果为扁平列表(漏评的题兜底为 0 分);
  6. + *
  7. 将合并结果 + 原始问答喂给 LLM 做二次汇总,生成统一的综合评价(这一步骤才是最终呈现在报告中的总评);
  8. + *
  9. 二次汇总失败时降级使用批次级合并结果,确保评估报告不会为空。
  10. + *
+ * + * @param referenceContext 参考基线(期望的回答标准),传 null 或空串时 LLM 自行评判。 + * 仅在 {@code voiceinterview} 模块的预评估场景中提供, + * {@code interview} 模块不用——这是为什么提供两个重载,而非统一的必选参数 + */ public EvaluationReport evaluate(ChatClient chatClient, String sessionId, List qaRecords, @@ -112,7 +165,9 @@ public EvaluationReport evaluate(ChatClient chatClient, log.info("开始评估面试: sessionId={}, 共{}题", sessionId, qaRecords.size()); String resumeContext = resumeText != null ? resumeText : ""; - // 超长简历截断,保留前 3000 字符(约 1500~2000 tokens),避免极端情况下 token 消耗过大 + // 截断至 3000 字符(约 1500~2000 tokens):保证简历上下文 + 参考基线 + N 道问答的总和 + // 不超过绝大多数 embedding 模型和 chat 模型的输入上限。截断在批次处理之前做, + // 避免每批都重复截断同一份简历 if (resumeContext.length() > 3000) { resumeContext = resumeContext.substring(0, 3000) + "\n...(简历内容过长,已截断)"; } @@ -148,6 +203,14 @@ private String loadPrompt(String path) throws IOException { return resource.getContentAsString(StandardCharsets.UTF_8); } + /** + * 顺序执行分批评估而非并行,原因有二: + *
    + *
  1. 保证评估结果的确定性顺序——题号递增,便于后续按索引合并;
  2. + *
  3. 避免同一 LLM Provider 的并发限流或资源争抢(常见于 Serverless 部署)。
  4. + *
+ * 某个批次失败(返回 null)不影响后续批次继续评估,最终合并逻辑能按题位填补兜底分数。 + */ private List evaluateInBatches(ChatClient chatClient, String sessionId, String resumeContext, List qaRecords, String referenceContext) { @@ -161,6 +224,16 @@ private List evaluateInBatches(ChatClient chatClient, String sessio return results; } + /** + * 单批次评估:组装 Prompt → 调用 LLM → 结构化输出 → 返回 DTO。 + * 失败时返回 null 而非抛异常,因为: + *
    + *
  1. 批间解耦——某一题评估失败不应牵连其他题目;
  2. + *
  3. 合并逻辑已具备兜底能力(0 分 + 占位文本),无需上层额外处理。
  4. + *
+ * 格式指令({@link BeanOutputConverter#getFormat})追加到 system prompt 末尾而非嵌在 user prompt 中, + * 因为 AI 模型通常将 system prompt 的内容视为更强的约束指令格式,结构化输出命中率更高。 + */ private BatchReportDTO evaluateBatch(ChatClient chatClient, String sessionId, String resumeContext, String referenceContext, List batch) { @@ -199,6 +272,11 @@ private String buildQARecords(List batch) { return sb.toString(); } + /** + * 将各批次的评估结果按原始题序合并为扁平列表。 + * LLM 可能漏评某些题目(返回数量少于预期),或整批评估失败(report == null), + * 此时用 0 分 + 占位文本填充缺失位,保证返回列表长度 = 题目总数,后续 buildReport 无需检查越界。 + */ private List mergeQuestionEvaluations(List batchResults) { List merged = new ArrayList<>(); for (BatchResult result : batchResults) { @@ -221,6 +299,10 @@ private List mergeQuestionEvaluations(List batchRe return merged; } + /** + * 合并各批次的总体评语。批次间用双换行分隔,保留 LLM 对各批次独立的语段边界, + * 比用句号/逗号拼接更易于阅读(且二次汇总 LLM 也能据此识别分段)。 + */ private String mergeOverallFeedback(List batchResults) { String feedback = batchResults.stream() .map(BatchResult::report) @@ -230,6 +312,12 @@ private String mergeOverallFeedback(List batchResults) { return feedback.isBlank() ? "本次面试已完成分批评估,但未生成有效综合评语。" : feedback; } + /** + * 合并各批次的优点/改进点列表。 + * 使用 {@link LinkedHashSet} 在去重的同时保留原始出现顺序——同一优点被多个批次提及说明重要性高, + * 保留在前更能反映 LLM 的关注焦点。 + * 限制 8 条:评估报告 UI 通常在折叠区域展示列表,条目过多会过度撑大报告篇幅。 + */ private List mergeListItems(List batchResults, boolean strengthsMode) { Set merged = new LinkedHashSet<>(); for (BatchResult result : batchResults) { @@ -245,6 +333,19 @@ private List mergeListItems(List batchResults, boolean stre return merged.stream().limit(8).toList(); } + /** + * 二次汇总:将各批次的分散评估结果送入 LLM 做一次综合提炼。 + *

+ * 这一步之所以必要,是因为分批评估中每批 LLM 只看到自己那几道题的问答,无法产出全局视角的总评。 + * 二次汇总的 prompt 包含三类信息: + *

    + *
  1. 分维度的平均分(categorySummary)——让 LLM 看到强弱势领域;
  2. + *
  3. 每道题的简要评估摘要(questionHighlights)——提供逐题粒度但不暴露完整原文(已截断),节省 tokens;
  4. + *
  5. 分批聚合的 fallback 总评/优点/改进点——LLM 即使拒绝输出也可降级使用。
  6. + *
+ * 降级设计:任一字段为空时退守 fallback 值;调用本身抛异常时使用完整的 fallback SummaryDTO。 + * 这就是统一评估"最少也有基础结果"的最后兜底。 + */ private SummaryDTO summarizeBatchResults( ChatClient chatClient, String sessionId, String resumeContext, String referenceContext, List qaRecords, List evaluations, @@ -279,6 +380,15 @@ private SummaryDTO summarizeBatchResults( } } + /** + * 合并 LLM 输出与 fallback 列表,优先使用 LLM 输出但确保不为空。 + * null 与 empty 的区别很重要: + *
    + *
  • primary == null → LLM 未生成该字段(结构化输出字段缺失),退到 fallback;
  • + *
  • primary.isEmpty() → LLM 生成了一个空数组,视为"没有值得列出的项",同样退到 fallback;
  • + *
  • primary 有值但全部是空白字符串 → 清理后变空,退到 fallback。
  • + *
+ */ private List sanitizeItems(List primary, List fallback) { List source = (primary != null && !primary.isEmpty()) ? primary : fallback; if (source == null || source.isEmpty()) return List.of(); @@ -287,6 +397,16 @@ private List sanitizeItems(List primary, List fallback) .map(String::trim).distinct().limit(8).toList(); } + /** + * 将内部评估数据组装为 {@link EvaluationReport} 输出对象。 + *

+ * 关键安全设计: + *

    + *
  • 未回答的题强制 0 分,即使 LLM 给出了评分(防止 AI 对"未回答"给出同情分);
  • + *
  • evaluations 长度不足时(合并阶段已尽力填充,仍差少数),靠 {@code i < evalSize} 边界保护,取不到时视作未评分;
  • + *
  • overallScore 的计算仅计入有回答的题目,避免未答题拉低总分。
  • + *
+ */ private EvaluationReport buildReport(String sessionId, List qaRecords, List evaluations, String overallFeedback, @@ -295,6 +415,7 @@ private EvaluationReport buildReport(String sessionId, List qaRecords, List referenceAnswers = new ArrayList<>(); Map> categoryScoresMap = new HashMap<>(); + // 先统计实际回答的题数,用于后续 overallScore 的除数——不应将未答题计入分母 long answeredCount = qaRecords.stream() .filter(q -> q.userAnswer() != null && !q.userAnswer().isBlank()) .count(); @@ -343,6 +464,12 @@ private EvaluationReport buildReport(String sessionId, List qaRecords, ); } + /** + * 生成分维度的平均分文本,作为二次汇总 prompt 的输入素材。 + * 之所以在此重新统计而非复用 {@link #buildReport} 中已完成的 categoryScores, + * 是因为这里的输出是供 LLM 读的纯文本,而非最终报告中的结构化数据——两者的消费者不同, + * 不共享同一份中间状态可以减少方法间耦合。 + */ private String buildCategorySummary(List qaRecords, List evaluations) { Map> categoryScores = new HashMap<>(); for (int i = 0; i < qaRecords.size(); i++) { @@ -363,6 +490,11 @@ private String buildCategorySummary(List qaRecords, List qaRecords, List evaluations) { List highlights = new ArrayList<>(); for (int i = 0; i < qaRecords.size(); i++) { From 436499b78574f60f7345f2558b08e75842154565 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E4=B8=B0=E4=BD=B3?= Date: Tue, 16 Jun 2026 11:24:23 +0800 Subject: [PATCH 09/11] =?UTF-8?q?chore(config):=20=E6=9B=B4=E6=96=B0?= =?UTF-8?q?=E6=95=B0=E6=8D=AE=E5=BA=93=E5=AF=86=E7=A0=81=E9=85=8D=E7=BD=AE?= =?UTF-8?q?=E7=A4=BA=E4=BE=8B?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 将 POSTGRES_PASSWORD 从默认值 'password' 更改为 '123456' - 保持其他数据库连接参数不变 --- .env.example | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.env.example b/.env.example index f6e5429e..7934c83a 100644 --- a/.env.example +++ b/.env.example @@ -37,7 +37,7 @@ POSTGRES_HOST=localhost POSTGRES_PORT=5432 POSTGRES_DB=interview_guide POSTGRES_USER=postgres -POSTGRES_PASSWORD=password +POSTGRES_PASSWORD=123456 # Redis(compose 映射 6379) REDIS_HOST=localhost From 3f074db2ffdbe0caa27d2188853eccd468622947 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E4=B8=B0=E4=BD=B3?= Date: Thu, 18 Jun 2026 16:16:01 +0800 Subject: [PATCH 10/11] =?UTF-8?q?refactor(ai):=20=E9=87=8D=E6=9E=84ApiPath?= =?UTF-8?q?Resolver=E4=B8=AD=E7=9A=84=E8=B6=85=E6=97=B6=E9=85=8D=E7=BD=AE?= =?UTF-8?q?=E5=8F=82=E6=95=B0=E7=B1=BB=E5=9E=8B=EF=BC=8C=E5=A2=9E=E5=8A=A0?= =?UTF-8?q?=E5=8F=AF=E8=AF=BB=E6=80=A7?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 移除未使用的导入包包括OpenAiApi、JdkClientHttpRequestFactory、RestClient、HttpClient - 将buildOpenAiClient方法的超时参数从int类型改为Duration类型 - 直接使用传入的Duration参数构建Timeout对象,移除毫秒转换逻辑 --- .../interview/guide/common/ai/ApiPathResolver.java | 11 +++-------- 1 file changed, 3 insertions(+), 8 deletions(-) diff --git a/app/src/main/java/interview/guide/common/ai/ApiPathResolver.java b/app/src/main/java/interview/guide/common/ai/ApiPathResolver.java index 558d1a3b..234530dc 100644 --- a/app/src/main/java/interview/guide/common/ai/ApiPathResolver.java +++ b/app/src/main/java/interview/guide/common/ai/ApiPathResolver.java @@ -6,12 +6,7 @@ import com.openai.core.Timeout; import com.openai.credential.BearerTokenCredential; import org.springframework.ai.openai.http.okhttp.SpringAiOpenAiHttpClient; -import org.springframework.ai.openai.api.OpenAiApi; -import org.springframework.http.client.JdkClientHttpRequestFactory; -import org.springframework.web.client.RestClient; -import java.net.http.HttpClient; -import java.time.Duration; import java.time.Duration; import java.util.regex.Pattern; @@ -39,10 +34,10 @@ public static OpenAIClient buildOpenAiClient(String baseUrl, String apiKey) { } public static OpenAIClient buildOpenAiClient(String baseUrl, String apiKey, - int connectTimeout, int readTimeout) { + Duration connectTimeout, Duration readTimeout) { Timeout timeout = Timeout.builder() - .connect(Duration.ofMillis(connectTimeout)) - .read(Duration.ofMillis(readTimeout)) + .connect(connectTimeout) + .read(readTimeout) .build(); ClientOptions options = ClientOptions.Companion.builder() .apiKey(apiKey) From 5d03b14f95f14b0459c6ba264a5c1ddda56792f1 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E4=B8=B0=E4=BD=B3?= Date: Thu, 18 Jun 2026 16:18:52 +0800 Subject: [PATCH 11/11] =?UTF-8?q?config(storage):=20=E7=BB=9F=E4=B8=80Rust?= =?UTF-8?q?FS=E5=92=8CMinIO=E7=9A=84=E7=94=A8=E6=88=B7=E5=90=8D=E5=AF=86?= =?UTF-8?q?=E7=A0=81=EF=BC=8C=E6=9C=AC=E5=9C=B0=E6=9E=84=E5=BB=BA=E4=B8=8D?= =?UTF-8?q?=E6=8A=A5=E9=94=99=EF=BC=8C=E7=94=9F=E4=BA=A7=E7=8E=AF=E5=A2=83?= =?UTF-8?q?=E8=82=AF=E5=AE=9A=E9=83=BD=E4=BC=9A=E6=94=B9=E6=8E=89=E7=9A=84?= =?UTF-8?q?=E3=80=82?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- docker-compose.dev.yml | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/docker-compose.dev.yml b/docker-compose.dev.yml index 34be1f4f..b081761c 100644 --- a/docker-compose.dev.yml +++ b/docker-compose.dev.yml @@ -40,8 +40,8 @@ services: container_name: interview-rustfs command: ["/data"] environment: - RUSTFS_ACCESS_KEY: ${RUSTFS_ACCESS_KEY:-rustfsadmin} - RUSTFS_SECRET_KEY: ${RUSTFS_SECRET_KEY:-rustfsadmin} + RUSTFS_ACCESS_KEY: ${APP_STORAGE_ACCESS_KEY:-admin} + RUSTFS_SECRET_KEY: ${APP_STORAGE_ACCESS_KEY:-admin} RUSTFS_CONSOLE_ENABLE: "true" ports: - "9000:9000"