feat(wiki): parallel processing + resilient LLM retry + hash-based skip (RFC-012 M1)

This commit is contained in:
matevip 2026-04-14 15:18:03 +08:00
parent aa439b6a3a
commit b9ed8219ba
8 changed files with 268 additions and 34 deletions

View File

@ -15,8 +15,30 @@ public class WikiProperties {
/** 是否启用 Wiki 知识库功能 */
private boolean enabled = true;
/** LLM 单次处理最大字符数(超过则分块) */
private int maxChunkSize = 30000;
/**
* LLM 单次处理最大字符数超过则分块
* <p>
* RFC-012默认从 30000 下调到 15000 chunk 输出 tokens 砍半并行饱和度更高
* 质量也更稳定中端模型qwen-plus/claude-sonnet建议 12000-20000
* 旗舰模型qwen-max/claude-opus可调大到 20000-30000
*/
private int maxChunkSize = 15000;
/**
* 同一次 processAllPending 下同时处理的原始材料数上限
* <p>
* RFC-012 Change 1保护共享的 @Async 线程池max=16不被 wiki 长时间占满
* 同时给材料级并发定一个可控的上限避免 LLM 提供方触发限流
*/
private int maxParallelRawMaterials = 3;
/**
* 单个材料内 chunk 的并行处理数上限
* <p>
* RFC-012 Change 1从硬编码 3 提到 5并暴露为配置项默认总并发为
* maxParallelRawMaterials × maxParallelChunks = 15仍在常见 60 RPM 限额下
*/
private int maxParallelChunks = 5;
/** 注入 agent prompt 的最大字符数 */
private int maxContextChars = 10000;

View File

@ -240,15 +240,20 @@ public class WikiController {
}
@RequireWorkspaceRole("member")
@Operation(summary = "重新处理原始材料")
@Operation(summary = "重新处理原始材料force=true 时绕过 content_hash 短路)")
@PostMapping("/knowledge-bases/{kbId}/raw/{rawId}/reprocess")
public R<Void> reprocessRaw(@PathVariable Long kbId, @PathVariable Long rawId,
@RequestParam(value = "force", defaultValue = "false") boolean force,
@RequestHeader(value = "X-Workspace-Id", required = false) Long workspaceId) {
verifyKBWorkspace(kbId, workspaceId);
WikiRawMaterialEntity raw = rawService.getById(rawId);
if (raw == null || !kbId.equals(raw.getKbId())) {
return R.fail("Raw material not found in this knowledge base");
}
// RFC-012 Change 5force=true 时清空 last_processed_hash让下一次处理必然执行完整管线
if (force) {
rawService.setLastProcessedHash(rawId, null);
}
rawService.reprocess(rawId);
return R.ok();
}
@ -320,16 +325,27 @@ public class WikiController {
// ==================== Processing ====================
@RequireWorkspaceRole("member")
@Operation(summary = "触发知识库处理(异步)")
@Operation(summary = "触发知识库处理(异步)force=true 时清空所有 last_processed_hash 并重新入队全部材料")
@PostMapping("/knowledge-bases/{kbId}/process")
public R<Map<String, Object>> processKB(@PathVariable Long kbId,
@RequestParam(value = "force", defaultValue = "false") boolean force,
@RequestHeader(value = "X-Workspace-Id", required = false) Long workspaceId) {
verifyKBWorkspace(kbId, workspaceId);
List<WikiRawMaterialEntity> pending = rawService.listPending(kbId);
for (WikiRawMaterialEntity raw : pending) {
List<WikiRawMaterialEntity> targets;
if (force) {
// 强制重处理所有非 pending 的材料重置为 pending并清空 hash 短路
targets = rawService.listByKbId(kbId);
for (WikiRawMaterialEntity r : targets) {
rawService.setLastProcessedHash(r.getId(), null);
rawService.reprocess(r.getId()); // reprocess 会把状态设为 pending 并发布事件
}
return R.ok(Map.of("queued", targets.size(), "force", true));
}
targets = rawService.listPending(kbId);
for (WikiRawMaterialEntity raw : targets) {
eventPublisher.publishEvent(new WikiProcessingEvent(this, raw.getId(), kbId));
}
return R.ok(Map.of("queued", pending.size()));
return R.ok(Map.of("queued", targets.size(), "force", false));
}
@RequireWorkspaceRole("viewer")

View File

@ -1,34 +1,60 @@
package vip.mate.wiki.event;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.springframework.context.event.EventListener;
import org.springframework.scheduling.annotation.Async;
import org.springframework.stereotype.Component;
import vip.mate.wiki.WikiProperties;
import vip.mate.wiki.service.WikiProcessingService;
import java.util.concurrent.Semaphore;
/**
* Wiki 处理事件监听器
* <p>
* 异步处理原始材料消化事件
* RFC-012 Change 1
* <ul>
* <li>事件到达后立即提交到 wiki 自己的虚拟线程执行器不占用全局 @Async 线程池max=16</li>
* <li>通过信号量限制同时处理的材料数 ({@link WikiProperties#getMaxParallelRawMaterials()})
* 避免大批量上传瞬间触发 LLM 限流</li>
* </ul>
*
* @author MateClaw Team
*/
@Slf4j
@Component
@RequiredArgsConstructor
public class WikiProcessingListener {
private final WikiProcessingService processingService;
private final Semaphore rawMaterialSemaphore;
public WikiProcessingListener(WikiProcessingService processingService, WikiProperties properties) {
this.processingService = processingService;
int parallel = Math.max(1, properties.getMaxParallelRawMaterials());
this.rawMaterialSemaphore = new Semaphore(parallel);
log.info("[Wiki] Listener initialized with rawMaterialSemaphore permits={}", parallel);
}
@Async
@EventListener
public void onWikiProcessing(WikiProcessingEvent event) {
log.info("[Wiki] Processing event received: rawId={}, kbId={}", event.getRawMaterialId(), event.getKbId());
// 立即把实际处理派发到 wiki 的虚拟线程池释放事件发布者线程和共享的 @Async
WikiProcessingService.WIKI_EXECUTOR.submit(() -> runWithSemaphore(event.getRawMaterialId()));
}
private void runWithSemaphore(Long rawId) {
try {
processingService.processRawMaterial(event.getRawMaterialId());
rawMaterialSemaphore.acquire();
} catch (InterruptedException e) {
Thread.currentThread().interrupt();
log.warn("[Wiki] Interrupted while waiting for processing slot, rawId={}", rawId);
return;
}
try {
processingService.processRawMaterial(rawId);
} catch (Exception e) {
log.error("[Wiki] Async processing failed for rawId={}: {}", event.getRawMaterialId(), e.getMessage(), e);
log.error("[Wiki] Async processing failed for rawId={}: {}", rawId, e.getMessage(), e);
} finally {
rawMaterialSemaphore.release();
}
}
}

View File

@ -49,6 +49,9 @@ public class WikiRawMaterialEntity {
/** 上次处理时间 */
private LocalDateTime lastProcessedAt;
/** 上次成功处理时的 content_hash用于重处理时的短路判断 */
private String lastProcessedHash;
/** 错误信息 */
private String errorMessage;

View File

@ -47,15 +47,23 @@ public class WikiProcessingService {
private final AgentGraphBuilder agentGraphBuilder;
private final ObjectMapper objectMapper;
/** 并行 chunk 处理执行器JDK 21 虚拟线程) */
private static final ExecutorService WIKI_EXECUTOR = Executors.newVirtualThreadPerTaskExecutor();
/** 最大并行 chunk 数 */
private static final int MAX_PARALLEL_CHUNKS = 3;
/** 并行 chunk / 材料处理执行器JDK 21 虚拟线程Listener 跨包需要引用,故 public */
public static final ExecutorService WIKI_EXECUTOR = Executors.newVirtualThreadPerTaskExecutor();
/**
* 处理单个原始材料
*/
public void processRawMaterial(Long rawId) {
processRawMaterial(rawId, false);
}
/**
* 处理单个原始材料支持强制重跑
*
* @param rawId 材料 ID
* @param force true 时忽略 content_hash 短路RFC-012 Change 5用于模型/提示词变更后的强制重跑
*/
public void processRawMaterial(Long rawId, boolean force) {
// CAS 式抢占防止并发重复处理
if (!rawService.claimForProcessing(rawId)) {
log.debug("[Wiki] Raw material {} already claimed or not pending, skipping", rawId);
@ -68,6 +76,15 @@ public class WikiProcessingService {
return;
}
// RFC-012 Change 5 content_hash 与上次成功处理时一致直接短路
if (!force
&& raw.getContentHash() != null
&& raw.getContentHash().equals(raw.getLastProcessedHash())) {
rawService.updateProcessingStatus(rawId, "completed", "Skipped: content unchanged since last processing");
log.info("[Wiki] Skip reprocessing raw={} (content unchanged, hash={})", rawId, raw.getContentHash());
return;
}
WikiKnowledgeBaseEntity kb = kbService.getById(raw.getKbId());
if (kb == null) {
log.warn("[Wiki] Knowledge base not found for raw material: kbId={}", raw.getKbId());
@ -115,8 +132,14 @@ public class WikiProcessingService {
// 部分成功有些 chunk 失败但有些产出了页面
rawService.updateProcessingStatus(rawId, "partial",
failedChunks + " of " + totalChunks + " chunks failed, " + totalPages + " pages generated");
// Review Bug 1partial 不写 lastProcessedHashpartial 的语义就是"还有失败、需要再跑"
// 写了会导致下次用户点"重新处理" hash 短路直接跳过永远没机会修失败的 chunk
} else {
rawService.updateProcessingStatus(rawId, "completed", null);
// RFC-012 Change 5记录本次成功处理时的 hash供下次短路判断
if (raw.getContentHash() != null) {
rawService.setLastProcessedHash(rawId, raw.getContentHash());
}
}
int pageCount = pageService.countByKbId(kb.getId());
kbService.setPageCount(kb.getId(), pageCount);
@ -134,6 +157,8 @@ public class WikiProcessingService {
/**
* 处理知识库中所有待处理的原始材料
* <p>
* RFC-012 Change 1材料级并行 {@link WikiProperties#getMaxParallelRawMaterials()} 约束
*/
public void processAllPending(Long kbId) {
List<WikiRawMaterialEntity> pendingList = rawService.listPending(kbId);
@ -141,10 +166,29 @@ public class WikiProcessingService {
log.info("[Wiki] No pending raw materials for kbId={}", kbId);
return;
}
log.info("[Wiki] Processing {} pending raw materials for kbId={}", pendingList.size(), kbId);
int parallel = Math.max(1, properties.getMaxParallelRawMaterials());
log.info("[Wiki] Processing {} pending raw materials for kbId={} with parallelism={}",
pendingList.size(), kbId, parallel);
Semaphore rawSem = new Semaphore(parallel);
List<CompletableFuture<Void>> futures = new ArrayList<>(pendingList.size());
for (WikiRawMaterialEntity raw : pendingList) {
processRawMaterial(raw.getId());
final Long rawId = raw.getId();
futures.add(CompletableFuture.runAsync(() -> {
try {
rawSem.acquire();
} catch (InterruptedException e) {
Thread.currentThread().interrupt();
return;
}
try {
processRawMaterial(rawId);
} finally {
rawSem.release();
}
}, WIKI_EXECUTOR));
}
CompletableFuture.allOf(futures.toArray(new CompletableFuture[0])).join();
}
/**
@ -171,7 +215,8 @@ public class WikiProcessingService {
}
// Phase 2: 并行处理Semaphore 限制并发数
Semaphore semaphore = new Semaphore(MAX_PARALLEL_CHUNKS);
int parallelChunks = Math.max(1, properties.getMaxParallelChunks());
Semaphore semaphore = new Semaphore(parallelChunks);
AtomicInteger totalPages = new AtomicInteger(0);
AtomicInteger failedChunks = new AtomicInteger(0);
@ -292,19 +337,12 @@ public class WikiProcessingService {
.replace("{raw_title}", raw.getTitle())
.replace("{raw_content}", textContent);
// 调用 LLM
String llmResponse;
try {
ChatModel chatModel = buildChatModel();
Prompt prompt = new Prompt(List.of(
new SystemMessage(systemPrompt),
new UserMessage(userPrompt)
));
ChatResponse response = chatModel.call(prompt);
llmResponse = response.getResult().getOutput().getText();
} catch (Exception e) {
throw new RuntimeException("LLM call failed: " + e.getMessage(), e);
}
// 调用 LLM带无限重试仅在模型不可用时终止
Prompt prompt = new Prompt(List.of(
new SystemMessage(systemPrompt),
new UserMessage(userPrompt)
));
String llmResponse = callLlmWithResilientRetry(prompt, "chunk of raw=" + raw.getId());
// 解析并持久化页面
return applyLlmResponse(kb.getId(), raw.getId(), llmResponse);
@ -408,6 +446,118 @@ public class WikiProcessingService {
return agentGraphBuilder.buildRuntimeChatModel(defaultModel);
}
/**
* 调用 LLM"任务完成或模型不可用才终止"的重试策略
* <p>
* 可重试一直重试直到成功网络抖动5xx429 限流超时连接中断内容过滤偶发JSON 空输出
* <p>
* 立即终止模型不可用401/403 认证失败模型不存在quota 用尽非法 API key
* InterruptedException优雅关停
* <p>
* 使用指数退避1s 2s 4s ... 封顶 60s无最大尝试次数
*/
private String callLlmWithResilientRetry(Prompt prompt, String ctx) {
long backoffMs = 1000;
final long maxBackoffMs = 60_000;
int attempt = 0;
while (true) {
attempt++;
try {
ChatModel chatModel = buildChatModel();
ChatResponse response = chatModel.call(prompt);
if (response == null || response.getResult() == null
|| response.getResult().getOutput() == null
|| response.getResult().getOutput().getText() == null
|| response.getResult().getOutput().getText().isBlank()) {
throw new TransientLlmException("Empty response from model");
}
if (attempt > 1) {
log.info("[Wiki] LLM call for {} succeeded on attempt {}", ctx, attempt);
}
return response.getResult().getOutput().getText();
} catch (Throwable t) {
if (Thread.currentThread().isInterrupted()) {
throw new RuntimeException("LLM call interrupted for " + ctx, t);
}
if (isFatalModelError(t)) {
log.error("[Wiki] LLM unavailable (fatal) for {} after {} attempts: {}",
ctx, attempt, t.getMessage());
throw new RuntimeException("LLM unavailable: " + t.getMessage(), t);
}
log.warn("[Wiki] LLM transient failure for {} attempt={}, retrying in {}ms: {}",
ctx, attempt, backoffMs, t.getMessage());
try {
Thread.sleep(backoffMs);
} catch (InterruptedException ie) {
Thread.currentThread().interrupt();
throw new RuntimeException("LLM retry interrupted for " + ctx, ie);
}
backoffMs = Math.min(maxBackoffMs, backoffMs * 2);
}
}
}
/**
* 判断是否为"模型不可用"级别的致命错误不重试立即终止
* <p>
* 三类视为 fatal
* <ul>
* <li><b>鉴权 / 配额 / 模型不存在</b>401/403invalid api keymodel not foundquota 用尽</li>
* <li><b>prompt 结构性错误</b>上下文超长max_tokens 限制prompt too long重试也得同样结果</li>
* <li><b>内容审核过滤</b>content_filter 触发 safety 挡下的 prompt 重试也是同样结果</li>
* </ul>
* 其余网络超时5xx429 限流偶发空响应均视为瞬时按指数退避持续重试
* <p>
* 说明关键字启发式在极少数场景可能误判例如瞬时错误的 message 恰好含 "authentication"
* 但实际云厂商 SDK 的错误消息规范度较高这个风险可接受
*/
private boolean isFatalModelError(Throwable t) {
Throwable cur = t;
int depth = 0;
while (cur != null && depth < 8) {
String msg = cur.getMessage();
if (msg != null) {
String m = msg.toLowerCase();
// 鉴权 / 配额 / 模型不存在HTTP 401/403 + 提供方错误字段
if (m.contains("401") || m.contains("unauthorized")
|| m.contains("403") || m.contains("forbidden")
|| m.contains("invalid api key") || m.contains("invalid_api_key")
|| m.contains("authentication") || m.contains("api key not valid")
|| m.contains("model not found") || m.contains("model_not_found")
|| m.contains("invalidapikey") || m.contains("invalid_request_error")
|| m.contains("quota") || m.contains("insufficient_quota")
|| m.contains("no default model") || m.contains("model configuration")) {
return true;
}
// Review Bug 3prompt 结构性错误重试也得同样结果立即终止
if (m.contains("context_length_exceeded")
|| m.contains("context length")
|| m.contains("maximum context")
|| m.contains("max_tokens")
|| m.contains("prompt too long")
|| m.contains("input is too long")
|| m.contains("token limit")) {
return true;
}
// Review Bug 2内容审核过滤 safety 挡下的 prompt 重试也是同样结果
if (m.contains("content_filter")
|| m.contains("content filter")
|| m.contains("data_inspection_failed")
|| (m.contains("safety") && m.contains("block"))) {
return true;
}
}
cur = cur.getCause();
depth++;
}
return false;
}
/** 瞬时错误的内部标记异常,确保空响应也能走重试路径 */
private static class TransientLlmException extends RuntimeException {
TransientLlmException(String msg) { super(msg); }
}
private JsonNode parseJsonResponse(String response) {
if (response == null || response.isBlank()) return null;

View File

@ -190,6 +190,17 @@ public class WikiRawMaterialService {
rawMapper.updateById(entity);
}
/**
* 记录本次成功处理时的 content_hashRFC-012 Change 5 的短路依据
*/
@Transactional
public void setLastProcessedHash(Long id, String hash) {
WikiRawMaterialEntity entity = rawMapper.selectById(id);
if (entity == null) return;
entity.setLastProcessedHash(hash);
rawMapper.updateById(entity);
}
/**
* 重新处理重置状态为 pending 并发布事件
*/

View File

@ -0,0 +1,3 @@
-- V7: Add last_processed_hash to mate_wiki_raw_material for skip-if-unchanged optimization
-- RFC-012 Change 5: when reprocessing, skip LLM pipeline if content_hash == last_processed_hash
ALTER TABLE mate_wiki_raw_material ADD COLUMN IF NOT EXISTS last_processed_hash VARCHAR(64) DEFAULT NULL;

View File

@ -0,0 +1,3 @@
-- V7: Add last_processed_hash to mate_wiki_raw_material for skip-if-unchanged optimization
-- RFC-012 Change 5: when reprocessing, skip LLM pipeline if content_hash == last_processed_hash
ALTER TABLE mate_wiki_raw_material ADD COLUMN IF NOT EXISTS last_processed_hash VARCHAR(64) DEFAULT NULL;