From 64b5587f566a7561bddb9e2845980b6b752b2529 Mon Sep 17 00:00:00 2001 From: matevip Date: Mon, 29 Jun 2026 14:10:57 +0800 Subject: [PATCH] feat(wiki): route cheap ingest steps to a configurable light model --- .../java/vip/mate/wiki/job/WikiKbConfig.java | 10 ++ .../vip/mate/wiki/job/WikiKbConfigParser.java | 3 + .../GlobalDefaultStepModelStrategy.java | 12 +-- .../job/strategy/KbDefaultModelStrategy.java | 14 +-- .../job/strategy/WikiLightModelStrategy.java | 92 +++++++++++++++++++ .../src/main/resources/docs/en/wiki.md | 11 +++ .../src/main/resources/docs/zh/wiki.md | 11 +++ .../strategy/WikiLightModelStrategyTest.java | 77 ++++++++++++++++ 8 files changed, 216 insertions(+), 14 deletions(-) create mode 100644 mateclaw-server/src/main/java/vip/mate/wiki/job/strategy/WikiLightModelStrategy.java create mode 100644 mateclaw-server/src/test/java/vip/mate/wiki/job/strategy/WikiLightModelStrategyTest.java diff --git a/mateclaw-server/src/main/java/vip/mate/wiki/job/WikiKbConfig.java b/mateclaw-server/src/main/java/vip/mate/wiki/job/WikiKbConfig.java index 5230de2b..e7dcada2 100644 --- a/mateclaw-server/src/main/java/vip/mate/wiki/job/WikiKbConfig.java +++ b/mateclaw-server/src/main/java/vip/mate/wiki/job/WikiKbConfig.java @@ -31,6 +31,16 @@ public class WikiKbConfig { */ private Long wikiDefaultModelId; + /** + * KB-level lightweight chat model for the cheap, high-volume steps + * (route / enrich / summary / entity extraction). When set, those steps + * run on this cheaper model instead of the KB/system default, cutting token + * spend without touching page generation quality. {@code null} falls back to + * the system-level light model, then to normal routing. Strong steps + * (create_page / merge_page) ignore this field. + */ + private Long wikiLightModelId; + /** Per-step model overrides: "heavy_ingest.create_page" → modelId */ private Map stepModels; diff --git a/mateclaw-server/src/main/java/vip/mate/wiki/job/WikiKbConfigParser.java b/mateclaw-server/src/main/java/vip/mate/wiki/job/WikiKbConfigParser.java index d9300ce5..a1acab0e 100644 --- a/mateclaw-server/src/main/java/vip/mate/wiki/job/WikiKbConfigParser.java +++ b/mateclaw-server/src/main/java/vip/mate/wiki/job/WikiKbConfigParser.java @@ -52,6 +52,9 @@ public final class WikiKbConfigParser { } else if ("wikiDefaultModelId".equals(key)) { Long parsed = parseLong(value); if (parsed != null) config.setWikiDefaultModelId(parsed); + } else if ("wikiLightModelId".equals(key)) { + Long parsed = parseLong(value); + if (parsed != null) config.setWikiLightModelId(parsed); } else if (key.startsWith("stepModels.")) { Long parsed = parseLong(value); if (parsed != null) { diff --git a/mateclaw-server/src/main/java/vip/mate/wiki/job/strategy/GlobalDefaultStepModelStrategy.java b/mateclaw-server/src/main/java/vip/mate/wiki/job/strategy/GlobalDefaultStepModelStrategy.java index 3fd190a9..ec9914cd 100644 --- a/mateclaw-server/src/main/java/vip/mate/wiki/job/strategy/GlobalDefaultStepModelStrategy.java +++ b/mateclaw-server/src/main/java/vip/mate/wiki/job/strategy/GlobalDefaultStepModelStrategy.java @@ -9,12 +9,13 @@ import vip.mate.wiki.job.model.WikiProcessingJobEntity; import vip.mate.wiki.model.WikiKnowledgeBaseEntity; /** - * RFC-030: Final fallback strategy — uses the system default model. - * Cheap steps (ROUTE, ENRICH, SUMMARY) prefer a lighter/cheaper model; - * strong steps (CREATE_PAGE, MERGE_PAGE) use the default model. + * Final fallback strategy — uses the system default model for every step. + * Cheap steps that want a lighter model are handled earlier by + * {@link WikiLightModelStrategy} (Order 2) when a light model is configured; + * this strategy is the last resort and keeps all steps on the system default. */ @Component -@Order(3) +@Order(4) @RequiredArgsConstructor public class GlobalDefaultStepModelStrategy implements WikiStepModelStrategy { @@ -25,9 +26,6 @@ public class GlobalDefaultStepModelStrategy implements WikiStepModelStrategy { @Override public Long selectModelId(WikiProcessingJobEntity job, WikiKnowledgeBaseEntity kb, WikiJobStep step) { - // RFC-030: cheap steps (ROUTE, ENRICH, SUMMARY) should ideally use a lighter model, - // but ModelConfigService has no "cheapest chat model" concept yet. - // When per-step pricing metadata is added, this switch can differentiate. return modelConfigService.getDefaultModel().getId(); } } diff --git a/mateclaw-server/src/main/java/vip/mate/wiki/job/strategy/KbDefaultModelStrategy.java b/mateclaw-server/src/main/java/vip/mate/wiki/job/strategy/KbDefaultModelStrategy.java index ae03657c..3edc0706 100644 --- a/mateclaw-server/src/main/java/vip/mate/wiki/job/strategy/KbDefaultModelStrategy.java +++ b/mateclaw-server/src/main/java/vip/mate/wiki/job/strategy/KbDefaultModelStrategy.java @@ -12,14 +12,14 @@ import vip.mate.wiki.job.model.WikiProcessingJobEntity; import vip.mate.wiki.model.WikiKnowledgeBaseEntity; /** - * RFC-051 PR-1a: middle-priority strategy that resolves the KB-level default - * chat model ({@link WikiKbConfig#getWikiDefaultModelId()}). Sits between the - * per-step override ({@link KbConfigStepModelStrategy}, Order 1) and the - * system-wide default ({@link GlobalDefaultStepModelStrategy}, Order 3), - * yielding the chain prescribed by RFC-051 §10.1: + * RFC-051 PR-1a: resolves the KB-level default chat model + * ({@link WikiKbConfig#getWikiDefaultModelId()}). Sits below the per-step + * override ({@link KbConfigStepModelStrategy}, Order 1) and the cheap-step light + * model ({@link WikiLightModelStrategy}, Order 2), and above the system-wide + * default ({@link GlobalDefaultStepModelStrategy}, Order 4), yielding the chain: * *
- *   stepModels[step] -> wikiDefaultModelId -> system default
+ *   stepModels[step] -> (light model for cheap steps) -> wikiDefaultModelId -> system default
  * 
* * The frontend has long written {@code wikiDefaultModelId} into the KB config @@ -27,7 +27,7 @@ import vip.mate.wiki.model.WikiKnowledgeBaseEntity; */ @Slf4j @Component -@Order(2) +@Order(3) @RequiredArgsConstructor public class KbDefaultModelStrategy implements WikiStepModelStrategy { diff --git a/mateclaw-server/src/main/java/vip/mate/wiki/job/strategy/WikiLightModelStrategy.java b/mateclaw-server/src/main/java/vip/mate/wiki/job/strategy/WikiLightModelStrategy.java new file mode 100644 index 00000000..3a0b1af7 --- /dev/null +++ b/mateclaw-server/src/main/java/vip/mate/wiki/job/strategy/WikiLightModelStrategy.java @@ -0,0 +1,92 @@ +package vip.mate.wiki.job.strategy; + +import com.fasterxml.jackson.databind.ObjectMapper; +import lombok.extern.slf4j.Slf4j; +import org.springframework.core.annotation.Order; +import org.springframework.stereotype.Component; +import vip.mate.system.service.SystemSettingService; +import vip.mate.wiki.job.WikiJobStep; +import vip.mate.wiki.job.WikiKbConfig; +import vip.mate.wiki.job.WikiKbConfigParser; +import vip.mate.wiki.job.model.WikiProcessingJobEntity; +import vip.mate.wiki.model.WikiKnowledgeBaseEntity; + +import java.util.EnumSet; +import java.util.Set; + +/** + * Routes the cheap, high-volume wiki steps (route / enrich / summary / entity + * extraction) to a lightweight chat model so they don't bill at the premium + * page-generation model's rate. + * + *

The light model is resolved as: per-KB {@code wikiLightModelId} → + * system-level {@code wiki.lightModelId} setting. When neither is configured the + * strategy returns {@code null} and routing falls through to the existing chain + * ({@code wikiDefaultModelId} → system default), so behavior is unchanged until + * an admin opts in. + * + *

Order is between the explicit per-step override + * ({@link KbConfigStepModelStrategy}, Order 1) and the KB default + * ({@link KbDefaultModelStrategy}, Order 3): once a light model is configured it + * takes precedence over the KB default for the cheap steps, but a KB can still + * pin a specific model on any step via {@code stepModels.}. Strong steps + * (create_page / merge_page) are not handled here. + */ +@Slf4j +@Component +@Order(2) +public class WikiLightModelStrategy implements WikiStepModelStrategy { + + /** System-setting key for the global lightweight wiki model id. */ + static final String SETTING_KEY = "wiki.lightModelId"; + + /** Cheap, high-volume steps eligible for the lightweight model. */ + private static final Set CHEAP_STEPS = EnumSet.of( + WikiJobStep.ROUTE, WikiJobStep.ENRICH, WikiJobStep.SUMMARY, WikiJobStep.ENTITY_EXTRACTION); + + private final ObjectMapper objectMapper; + private final SystemSettingService systemSettingService; + + public WikiLightModelStrategy(ObjectMapper objectMapper, SystemSettingService systemSettingService) { + this.objectMapper = objectMapper; + this.systemSettingService = systemSettingService; + } + + @Override + public boolean supports(WikiJobStep step) { + return CHEAP_STEPS.contains(step); + } + + @Override + public Long selectModelId(WikiProcessingJobEntity job, WikiKnowledgeBaseEntity kb, WikiJobStep step) { + if (!CHEAP_STEPS.contains(step)) { + return null; + } + Long perKb = perKbLightModel(kb); + if (perKb != null) { + return perKb; + } + return systemLightModel(); + } + + private Long perKbLightModel(WikiKnowledgeBaseEntity kb) { + if (kb == null || kb.getConfigContent() == null) { + return null; + } + WikiKbConfig config = WikiKbConfigParser.parse(objectMapper, kb.getConfigContent()); + return config != null ? config.getWikiLightModelId() : null; + } + + private Long systemLightModel() { + String raw = systemSettingService.getString(SETTING_KEY, null); + if (raw == null || raw.isBlank()) { + return null; + } + try { + return Long.parseLong(raw.trim()); + } catch (NumberFormatException e) { + log.warn("[WikiLightModel] Invalid {} setting (not a model id): {}", SETTING_KEY, raw); + return null; + } + } +} diff --git a/mateclaw-server/src/main/resources/docs/en/wiki.md b/mateclaw-server/src/main/resources/docs/en/wiki.md index 32c30033..7374ab75 100644 --- a/mateclaw-server/src/main/resources/docs/en/wiki.md +++ b/mateclaw-server/src/main/resources/docs/en/wiki.md @@ -95,6 +95,17 @@ Eager ingest runs in two phases for an order-of-magnitude speedup: **Resumable**: interrupted mid-import? Hit "Reprocess" and only the unfinished pages re-run; everything already produced stays put. Documents larger than the embedding model's context get mean-pool sub-segmented automatically. +#### Save tokens: a light model for the cheap steps + +Digest runs several kinds of LLM step: route, merge generation, enrich, summary, entity extraction. The **route / enrich / summary / entity-extraction** steps are high-volume but lightweight — no need to run them on the same premium model as page merging. + +Point them at a cheaper model to cut token spend without touching page-generation quality: + +- **System-wide** — set `wiki.lightModelId` (a model id) in system settings; it applies to the cheap steps of every KB. +- **Per-KB override** — set `wikiLightModelId` in the KB config to override the system-wide value. + +Leave both unset and nothing changes (the cheap steps keep using the KB / system default). Precedence: `stepModels.` (pin a step) → light model (cheap steps only) → `wikiDefaultModelId` → system default. + ### Lazy: index now, compile later The pipeline collapses to four steps: diff --git a/mateclaw-server/src/main/resources/docs/zh/wiki.md b/mateclaw-server/src/main/resources/docs/zh/wiki.md index 0dc29fd1..c9db8c0b 100644 --- a/mateclaw-server/src/main/resources/docs/zh/wiki.md +++ b/mateclaw-server/src/main/resources/docs/zh/wiki.md @@ -95,6 +95,17 @@ eager 模式分两阶段,速度提了一个数量级: **可恢复**:中途断了?点"重新处理",只重跑未完成的页面,已生成的不动。超过模型上下文限制的文档,系统自动做 mean-pool 子段切分——你不用管。 +#### 省 token:给廉价步骤配轻量模型 + +消化会跑好几类 LLM 步骤:路由、合并生成、富化、摘要、实体抽取。其中 **路由 / 富化 / 摘要 / 实体抽取** 是高频但轻量的活,没必要和「合并生成」用同一个高价模型。 + +给它们指定一个便宜模型即可显著省 token,页面生成质量不受影响: + +- **系统级** —— 系统设置里配 `wiki.lightModelId`(一个模型 id),对所有知识库的廉价步骤生效; +- **每库覆盖** —— 知识库配置里写 `wikiLightModelId`,覆盖系统级设置。 + +不配则一切照旧(廉价步骤仍走 KB 默认 / 系统默认模型)。优先级:`stepModels.<步骤>`(钉死某步)→ 轻量模型(仅廉价步骤)→ `wikiDefaultModelId` → 系统默认。 + ### Lazy 模式:先入索引,按需出页面 链路缩成四步: diff --git a/mateclaw-server/src/test/java/vip/mate/wiki/job/strategy/WikiLightModelStrategyTest.java b/mateclaw-server/src/test/java/vip/mate/wiki/job/strategy/WikiLightModelStrategyTest.java new file mode 100644 index 00000000..774be46e --- /dev/null +++ b/mateclaw-server/src/test/java/vip/mate/wiki/job/strategy/WikiLightModelStrategyTest.java @@ -0,0 +1,77 @@ +package vip.mate.wiki.job.strategy; + +import com.fasterxml.jackson.databind.ObjectMapper; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; +import vip.mate.system.service.SystemSettingService; +import vip.mate.wiki.job.WikiJobStep; +import vip.mate.wiki.model.WikiKnowledgeBaseEntity; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertFalse; +import static org.junit.jupiter.api.Assertions.assertNull; +import static org.junit.jupiter.api.Assertions.assertTrue; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.when; + +class WikiLightModelStrategyTest { + + private SystemSettingService settings; + private WikiLightModelStrategy strategy; + + @BeforeEach + void setUp() { + settings = mock(SystemSettingService.class); + strategy = new WikiLightModelStrategy(new ObjectMapper(), settings); + } + + private WikiKnowledgeBaseEntity kb(String configContent) { + WikiKnowledgeBaseEntity kb = new WikiKnowledgeBaseEntity(); + kb.setConfigContent(configContent); + return kb; + } + + @Test + @DisplayName("supports() only the cheap, high-volume steps") + void supportsOnlyCheapSteps() { + assertTrue(strategy.supports(WikiJobStep.ROUTE)); + assertTrue(strategy.supports(WikiJobStep.ENRICH)); + assertTrue(strategy.supports(WikiJobStep.SUMMARY)); + assertTrue(strategy.supports(WikiJobStep.ENTITY_EXTRACTION)); + assertFalse(strategy.supports(WikiJobStep.CREATE_PAGE)); + assertFalse(strategy.supports(WikiJobStep.MERGE_PAGE)); + } + + @Test + @DisplayName("No light model configured anywhere → null (behavior unchanged)") + void noLightModelConfigured() { + when(settings.getString(WikiLightModelStrategy.SETTING_KEY, null)).thenReturn(null); + assertNull(strategy.selectModelId(null, null, WikiJobStep.ROUTE)); + assertNull(strategy.selectModelId(null, kb("{}"), WikiJobStep.SUMMARY)); + } + + @Test + @DisplayName("System light model applies to cheap steps") + void systemLightModelApplies() { + when(settings.getString(WikiLightModelStrategy.SETTING_KEY, null)).thenReturn("777"); + assertEquals(777L, strategy.selectModelId(null, null, WikiJobStep.ENRICH)); + // Strong steps are never routed here even if asked directly. + assertNull(strategy.selectModelId(null, null, WikiJobStep.CREATE_PAGE)); + } + + @Test + @DisplayName("Per-KB light model overrides the system light model") + void perKbOverridesSystem() { + when(settings.getString(WikiLightModelStrategy.SETTING_KEY, null)).thenReturn("777"); + Long picked = strategy.selectModelId(null, kb("{\"wikiLightModelId\": 555}"), WikiJobStep.SUMMARY); + assertEquals(555L, picked); + } + + @Test + @DisplayName("Invalid system setting is ignored (null, no crash)") + void invalidSystemSetting() { + when(settings.getString(WikiLightModelStrategy.SETTING_KEY, null)).thenReturn("not-a-number"); + assertNull(strategy.selectModelId(null, null, WikiJobStep.ROUTE)); + } +}