feat(h5): add LLM intent router admin controls and shadow verification.

Expose shadow/canary router policy in ops admin, add FAQ rule fast-path,
and tighten router defaults (1200ms timeout, 0.65 confidence).
Includes verify-h5-llm-router-shadow for production canary rollout.

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
john
2026-08-01 16:48:01 +08:00
parent 005612029f
commit 43bc8bbc2b
14 changed files with 1391 additions and 80 deletions
+221 -30
View File
@@ -15,6 +15,10 @@ import {
resolveMemoryInterventionMode,
} from './memory-intervention.mjs';
import { filterMemoriesByQuery } from './memory-legacy-fallback.mjs';
import { matchDirectChatFaqRule } from './chat-intent-router-rules.mjs';
import { isGoalRunIntent } from './goal-run-intent.mjs';
export { matchDirectChatFaqRule, DIRECT_CHAT_FAQ_RULES, FAQ_EXCLUSION_PATTERNS } from './chat-intent-router-rules.mjs';
export const CHAT_INTENT_ROUTE = {
DIRECT_CHAT: 'direct_chat',
@@ -42,6 +46,7 @@ const ALLOWED_ROUTER_DECISION_FLAGS = new Set([
'force_deep_reasoning',
'code_task',
'long_running',
'goal_run',
]);
const AGENT_ORCHESTRATION_HEADER = '【Memind 任务编排】';
@@ -304,9 +309,9 @@ function resolveRouterFallbackRoute(env, overrides = {}) {
return raw === CHAT_INTENT_ROUTE.DIRECT_CHAT ? CHAT_INTENT_ROUTE.AGENT : raw;
}
const DEFAULT_ROUTER_TIMEOUT_MS = 2500;
const DEFAULT_ROUTER_TIMEOUT_MS = 1200;
const DEFAULT_ROUTER_MEMORY_LIMIT = 8;
const DEFAULT_ROUTER_MIN_CONFIDENCE = 0.55;
const DEFAULT_ROUTER_MIN_CONFIDENCE = 0.65;
const REALTIME_WEB_AGENT_BRIEF =
'先 load_skill → web;获取实时信息时同一轮并行调用 tkmind_search(专用联网搜索)和 web_search(内置联网搜索),合并去重并保留来源。向用户只称「联网搜索」,不要提具体搜索引擎或中间件名称。任一侧不可用时继续使用另一侧,必要时再用 fetch_url 读取可靠来源;禁止使用 search 技能查工作区,不要反复 scrape 同一站点。';
@@ -623,22 +628,17 @@ export function buildRouterNormalizedDecision(
const mode = ROUTER_DECISION_MODE.SSE;
let sessionHint = ROUTER_DECISION_SESSION_HINT.NEW;
if (sessionId) {
// Portal /agent/start already binds a Goose session before the first user turn.
// Reuse it on agent fallback (e.g. direct_chat_failed) instead of orphaning it.
sessionHint = ROUTER_DECISION_SESSION_HINT.REUSE;
}
if (
sessionId &&
!isDirectChatSessionId(sessionId) &&
sessionMessageCount != null &&
Number(sessionMessageCount) === 0
) {
sessionHint = ROUTER_DECISION_SESSION_HINT.NEW;
}
const flags = [];
if (isMemoryRecallQuestion(text)) flags.push('memory_recall');
if (forceDeepReasoning) flags.push('force_deep_reasoning');
if (toolMode === 'code') flags.push('code_task');
if (classification?.suggestedSkill) flags.push('selected_skill');
if (isGoalRunIntent(text)) flags.push('goal_run');
const normalizedFlags = [...new Set(flags)].filter((flag) => ALLOWED_ROUTER_DECISION_FLAGS.has(flag));
return {
@@ -693,8 +693,9 @@ export function logRouterDecisionShadow(
}
/**
* Apply router session_hint for goosed agent path without overriding hard rules.
* Returns null when gateway should start a fresh goosed session.
* Resolve the Goose session id for agent-run gateway execution.
* When Portal already bound a session, always reuse it; session_hint only
* requests a fresh Goose session when no agentSessionId is present yet.
*/
export function resolveGatewayAgentSessionId({
agentSessionId = null,
@@ -725,16 +726,63 @@ export function resolveGatewayAgentSessionId({
return sessionId;
}
if (decision.session_hint === ROUTER_DECISION_SESSION_HINT.NEW) {
return null;
}
return sessionId;
}
export function normalizeChatRouterCanaryUserIds(value) {
return normalizeAgentCanaryUserIds(value);
}
export function isChatLlmRouterEligible({
enabled = false,
canaryUserIds = [],
userId = null,
} = {}) {
if (!enabled) return false;
if (!Array.isArray(canaryUserIds) || canaryUserIds.length === 0) return true;
const normalizedUserId = String(userId ?? '').trim();
if (!normalizedUserId) return false;
return canaryUserIds.includes(normalizedUserId);
}
export function logChatLlmRouterShadow(
{
baseline = null,
llmResult = null,
userId = null,
sessionId = null,
routerMemoryItemsUsed = null,
} = {},
{ logger = console } = {},
) {
const payload = {
userId: userId ?? null,
sessionId: sessionId ?? null,
baselineRoute: baseline?.route ?? null,
baselineSource: baseline?.source ?? null,
llmRoute: llmResult?.route ?? null,
llmConfidence: llmResult?.confidence ?? null,
llmReason: llmResult?.reason ?? null,
routerMemoryItemsUsed,
wouldChangeRoute: Boolean(
llmResult?.route != null
&& baseline?.route != null
&& llmResult.route !== baseline.route,
),
};
const line = `[chat-llm-router-shadow] ${JSON.stringify(payload)}`;
if (typeof logger.warn === 'function') logger.warn(line);
else if (typeof logger.info === 'function') logger.info(line);
else logger.log(line);
}
export function resolveChatIntentRouterPolicy({ env = process.env, overrides = {} } = {}) {
return {
enabled: envFlag(env?.MEMIND_CHAT_LLM_ROUTER_ENABLED, false),
shadowMode: envFlag(env?.MEMIND_CHAT_LLM_ROUTER_SHADOW, false),
canaryUserIds: normalizeChatRouterCanaryUserIds(
overrides.canaryUserIds ?? env?.MEMIND_CHAT_ROUTER_CANARY_USER_IDS,
),
modelProviderKeyId: String(env?.MEMIND_CHAT_ROUTER_MODEL_PROVIDER_KEY_ID ?? '').trim() || null,
model: String(env?.MEMIND_CHAT_ROUTER_MODEL ?? '').trim() || null,
modelApiType: String(env?.MEMIND_CHAT_ROUTER_MODEL_API ?? '').trim() || 'chat',
@@ -811,6 +859,7 @@ export function buildAgentOrchestrationAgentText({
classification,
skillPrompt = '',
memoryContext = null,
goalContext = null,
}) {
const taskBody = String(displayText ?? '').trim();
const memoryLines = memoryContext?.injectionEnabled
@@ -823,6 +872,9 @@ export function buildAgentOrchestrationAgentText({
.filter(Boolean)
.slice(0, 8)
: [];
const goalEnvelope = goalContext?.injectionEnabled
? String(goalContext.envelope ?? '').trim()
: '';
const lines = [
`${AGENT_ORCHESTRATION_HEADER}以下为用户任务,请使用工具与技能实际执行并产出结果,不要只做文字描述。`,
`路由判定:${classification.reason}`,
@@ -830,6 +882,7 @@ export function buildAgentOrchestrationAgentText({
classification.suggestedSkill ? `建议 skill${classification.suggestedSkill}` : '',
buildImageGenerationInstruction(classification.imageGeneration),
skillPrompt,
goalEnvelope,
memoryLines.length
? [
'[Memory Context]',
@@ -847,7 +900,7 @@ export function buildAgentOrchestrationAgentText({
export function applyAgentOrchestrationToUserMessage(
userMessage,
classification,
{ grantedSkills = [], memoryContext = null } = {},
{ grantedSkills = [], memoryContext = null, goalContext = null } = {},
) {
const displayText = messageDisplayText(userMessage);
const skillPrompt = resolveSkillPrompt(classification?.suggestedSkill, grantedSkills, displayText);
@@ -856,6 +909,7 @@ export function applyAgentOrchestrationToUserMessage(
classification,
skillPrompt,
memoryContext,
goalContext,
});
const content = Array.isArray(userMessage?.content)
? userMessage.content.map((item, index) => {
@@ -894,7 +948,6 @@ export function classifyWithRules({
sessionMessageCount = null,
userMessage = null,
includeIntentPatterns = true,
llmRouterEnabled = false,
} = {}) {
const decisionContext = {
text,
@@ -992,6 +1045,16 @@ export function classifyWithRules({
if (includeIntentPatterns && normalized && isRealtimeInfoQuestion(normalized)) {
return finalizeRouterClassification(buildRealtimeInfoClassification(), decisionContext);
}
if (includeIntentPatterns && normalized) {
const faqMatch = matchDirectChatFaqRule(normalized);
if (faqMatch) {
return finalizeRouterClassification(normalizeClassification({
route: CHAT_INTENT_ROUTE.DIRECT_CHAT,
confidence: 0.94,
reason: faqMatch.reason,
}, { source: 'rule' }), decisionContext);
}
}
if (
includeIntentPatterns &&
normalized &&
@@ -1030,6 +1093,8 @@ export function createChatIntentRouter(options = {}) {
env,
overrides: pickDefined(options, [
'enabled',
'shadowMode',
'canaryUserIds',
'modelProviderKeyId',
'model',
'modelApiType',
@@ -1041,6 +1106,68 @@ export function createChatIntentRouter(options = {}) {
'fallbackRoute',
]),
});
function isLlmRouterEligibleForUser(userId) {
return isChatLlmRouterEligible({
enabled: policy.enabled,
canaryUserIds: policy.canaryUserIds,
userId,
});
}
function buildFallbackClassification(reason = '规则未命中,走 skill 默认 Agent 通道') {
return normalizeClassification({
route: policy.fallbackRoute,
confidence: 0.5,
reason,
}, { source: 'fallback', fallbackRoute: policy.fallbackRoute });
}
async function classifyWithLlm({
text,
grantedSkills = [],
routerContext = null,
} = {}) {
if (typeof llmProviderService?.createChatCompletion !== 'function') return null;
try {
const completion = await withTimeout(
llmProviderService.createChatCompletion({
providerKeyId: policy.modelProviderKeyId || undefined,
model: policy.model || undefined,
temperature: policy.temperature ?? 0,
messages: [
{ role: 'system', content: buildRouterSystemPrompt(grantedSkills) },
{
role: 'user',
content: buildRouterUserPrompt({
text,
routerContext: routerContext?.content ?? null,
}),
},
],
}),
policy.timeoutMs,
'Chat intent router',
);
if (!completion?.ok) return null;
const parsed = parseRouterJson(completion.reply);
if (!parsed) return null;
return normalizeClassification({
route: parsed.route,
confidence: parsed.confidence,
reason: parsed.reason,
suggested_skill: parsed.suggested_skill ?? parsed.suggestedSkill,
agent_brief: parsed.agent_brief ?? parsed.agentBrief,
providerKeyId: completion.providerKeyId ?? policy.modelProviderKeyId,
model: completion.model ?? policy.model,
}, { source: 'llm', fallbackRoute: policy.fallbackRoute });
} catch (err) {
logger?.warn?.(
`[chat-intent-router] LLM classify skipped: ${err instanceof Error ? err.message : err}`,
);
return null;
}
}
const agentMemoryPolicy = {
enabled: envFlag(env?.MEMORY_AGENT_RESOLVE_ENABLED, false),
mode: normalizeAgentInjectionMode(env?.MEMORY_AGENT_INJECTION_MODE),
@@ -1062,8 +1189,10 @@ export function createChatIntentRouter(options = {}) {
return {
enabled: true,
ruleRoutingEnabled: true,
llmRoutingEnabled: false,
llmRoutingEnabled: Boolean(policy.enabled && !policy.shadowMode),
llmRoutingShadow: Boolean(policy.enabled && policy.shadowMode),
configuredLlmEnabled: Boolean(policy.enabled),
llmRoutingCanaryUserCount: policy.canaryUserIds.length,
modelProviderKeyId: policy.modelProviderKeyId ?? null,
model: policy.model ?? null,
modelApiType: policy.modelApiType ?? 'chat',
@@ -1083,8 +1212,8 @@ export function createChatIntentRouter(options = {}) {
}
function isEnabled() {
// Rule + skill routing is always active. The LLM classifier stays disabled
// independently; returning false here would make the gateway skip rules too.
// Rule + skill routing is always active. LLM routing is separately gated by
// MEMIND_CHAT_LLM_ROUTER_ENABLED; returning false here would skip rules too.
return true;
}
@@ -1296,6 +1425,7 @@ export function createChatIntentRouter(options = {}) {
}),
}, decisionContext);
};
const llmRouterEligible = isLlmRouterEligibleForUser(userId);
const ruleResult = classifyWithRules({
text,
forceDeepReasoning,
@@ -1304,20 +1434,81 @@ export function createChatIntentRouter(options = {}) {
sessionMessageCount,
userMessage,
includeIntentPatterns: true,
llmRouterEnabled: false,
});
if (ruleResult) return finalizeWithCoercion(ruleResult);
return finalizeWithCoercion(normalizeClassification({
route: policy.fallbackRoute,
confidence: 0.5,
reason: '规则未命中,走 skill 默认 Agent 通道',
}, { source: 'fallback' }));
const baseline = buildFallbackClassification();
if (!llmRouterEligible) {
return finalizeWithCoercion(baseline);
}
const memoryPromise = policy.memoryResolveEnabled && userId
? resolveRouterContext({
userId,
sessionId,
text,
forceDeepReasoning,
})
.then((result) => buildRouterContext(result))
.catch(() => buildRouterContext(null))
: Promise.resolve(buildRouterContext(null));
// Memory is optional routing hint; run LLM in parallel to cut serial latency.
const [routerContext, llmResult] = await Promise.all([
memoryPromise,
classifyWithLlm({
text,
grantedSkills,
routerContext: null,
}),
]);
if (policy.shadowMode) {
logChatLlmRouterShadow({
baseline,
llmResult,
userId,
sessionId,
routerMemoryItemsUsed: routerContext?.itemsUsed ?? 0,
}, { logger });
return finalizeWithCoercion({
...baseline,
llmShadow: llmResult
? {
route: llmResult.route,
confidence: llmResult.confidence,
reason: llmResult.reason,
source: llmResult.source ?? 'llm',
wouldChangeRoute: llmResult.route !== baseline.route,
}
: {
skipped: true,
reason: 'llm_unavailable_or_low_signal',
wouldChangeRoute: false,
},
});
}
if (!llmResult || llmResult.confidence < policy.minConfidence) {
return finalizeWithCoercion({
...baseline,
llmSuggestion: llmResult
? {
route: llmResult.route,
confidence: llmResult.confidence,
reason: llmResult.reason,
source: llmResult.source ?? 'llm',
}
: null,
});
}
return finalizeWithCoercion(llmResult);
}
async function classifySessionAction({ text } = {}) {
// The general H5 router intentionally remains rule/skill-only. Session
// action classification is separately opt-in because it runs before every
// WeChat Agent request that contains a context hint.
// WeChat session action classification is separately opt-in because it runs
// before every WeChat Agent request that contains a context hint.
if (!policy.enabled || typeof llmProviderService?.createChatCompletion !== 'function') return null;
try {
const completion = await withTimeout(