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:
+221
-30
@@ -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(
|
||||
|
||||
Reference in New Issue
Block a user