perf(bridge): 商品检索改用短关键词并恢复分阶段计时

This commit is contained in:
wangliang
2026-10-23 16:32:03 +08:00
parent a0d386b752
commit 0959777048
6 changed files with 174 additions and 12 deletions
+29 -7
View File
@@ -24,6 +24,7 @@ const { createEscalation } = require('./escalation');
const { createExperimentManager } = require('./experiments');
const { createTraceWriter } = require('./trace-writer');
const { extractLeadFields } = require('./lead-extract');
const { extractProductKeyword } = require('./product-query');
const crypto = require('crypto');
const prom = require('./prom');
@@ -107,7 +108,7 @@ function createAgent(cfg, deps = {}) {
async function runOnce(conv, webhookMessage) {
if (!llm.enabled) throw new Error('LLM is not configured');
const startedAt = Date.now();
const stats = { llmCalls: 0, promptTokens: 0, completionTokens: 0, totalTokens: 0, toolCalls: [], toolResults: [] };
const stats = { llmCalls: 0, promptTokens: 0, completionTokens: 0, totalTokens: 0, toolCalls: [], toolResults: [], llmMs: 0, toolMs: 0 };
const traceSteps = [];
const traceId = `${conv.uuid}:${webhookMessage.uuid || ''}`;
const profile = resolveSiteProfile(cfg, { inboxId: String(conv.inbox_id || '') });
@@ -157,6 +158,8 @@ function createAgent(cfg, deps = {}) {
// 预检索:政策问题先查 KB,商品问题先查商品,避免模型跳过工具直接编。
const intent = quickClassify(currentText);
// 商品预检索/强制检索只发清洗后的短关键词(旧 bridge 思路):原文含数量/货币会让商品 RPC 慢 10 倍以上。
const productKeyword = intent === INTENTS.PRODUCT_SEARCH ? extractProductKeyword(currentText) : '';
const emotion = detectEmotion(currentText);
const emotionStrategy = escalation.applyEmotionStrategy(emotion);
const maxSteps = emotionStrategy.maxToolTurns || MAX_TOOL_STEPS;
@@ -171,11 +174,13 @@ function createAgent(cfg, deps = {}) {
let preResult = null;
try {
let preTool = null;
// 商品意图清洗后没有可用商品词(如纯数量/纯问候)→ 跳过预检索,交给 LLM 决定
if (intent === INTENTS.POLICY_FAQ) preTool = byName.get('search_kb');
else if (intent === INTENTS.PRODUCT_SEARCH) preTool = byName.get('search_products');
else if (intent === INTENTS.PRODUCT_SEARCH && productKeyword) preTool = byName.get('search_products');
if (preTool) {
const started = Date.now();
preResult = await preTool.handler({ query: currentText, limit: 5 }, ctx);
preResult = await preTool.handler({ query: preTool.name === 'search_products' ? productKeyword : currentText, limit: 5 }, ctx);
stats.toolMs += Date.now() - started;
stats.toolCalls.push({ name: preTool.name, ms: Date.now() - started, ok: true, forced: true });
stats.toolResults.push(preResult);
console.log(JSON.stringify({ ts: new Date().toISOString(), agent: 'tool', tool: preTool.name, forced: true, ms: Date.now() - started, conversation: conv.uuid, ok: true }));
@@ -229,6 +234,7 @@ function createAgent(cfg, deps = {}) {
maxTokens: (variant.overriddenParams && variant.overriddenParams.maxTokens) ?? 1200,
});
stats.llmCalls += 1;
stats.llmMs += Date.now() - llmStarted;
traceSteps.push({ step_type: 'llm', turn: step, model: llm.model, prompt_tokens: response.usage?.prompt_tokens, completion_tokens: response.usage?.completion_tokens, duration_ms: Date.now() - llmStarted, finish_reason: response.finishReason || null });
if (response.usage) {
stats.promptTokens += Number(response.usage.prompt_tokens) || 0;
@@ -237,16 +243,17 @@ function createAgent(cfg, deps = {}) {
}
// 兜底(旧 bridge 同款):product_search 意图下首轮 LLM 一个工具都没调时,
// 丢弃它凭记忆编的回答,强制查一次真实商品结果再让它重答,避免幻觉商品/链接进入回复。
if (!(response.toolCalls && response.toolCalls.length) && step === 0 && intent === INTENTS.PRODUCT_SEARCH && !stats.toolCalls.length) {
if (!(response.toolCalls && response.toolCalls.length) && step === 0 && intent === INTENTS.PRODUCT_SEARCH && productKeyword && !stats.toolCalls.length) {
const searchTool = byName.get('search_products');
if (searchTool) {
const started = Date.now();
try {
const forced = await searchTool.handler({ query: currentText, limit: 5 }, ctx);
const forced = await searchTool.handler({ query: productKeyword, limit: 5 }, ctx);
stats.toolMs += Date.now() - started;
stats.toolCalls.push({ name: 'search_products', ms: Date.now() - started, ok: true, forced: true });
stats.toolResults.push(forced);
traceSteps.push({ step_type: 'tool', turn: step, tool_name: 'search_products', duration_ms: Date.now() - started });
messages.push({ role: 'assistant', content: '', tool_calls: [{ id: 'forced-search-products', type: 'function', function: { name: 'search_products', arguments: JSON.stringify({ query: currentText, limit: 5 }) } }] });
messages.push({ role: 'assistant', content: '', tool_calls: [{ id: 'forced-search-products', type: 'function', function: { name: 'search_products', arguments: JSON.stringify({ query: productKeyword, limit: 5 }) } }] });
messages.push({ role: 'tool', tool_call_id: 'forced-search-products', content: markToolResult('search_products', JSON.stringify(forced).slice(0, 6000)) });
console.log(JSON.stringify({ ts: new Date().toISOString(), agent: 'forced_search_products', conversation: conv.uuid, ok: true }));
continue;
@@ -274,6 +281,7 @@ function createAgent(cfg, deps = {}) {
result = { error: err.code || err.message || 'tool_failed', message: err.message || String(err) };
}
prom.inc('bridge_ai_tool_calls_total', { tool: name, ok: !(result && result.error) });
stats.toolMs += Date.now() - started;
traceSteps.push({ step_type: 'tool', turn: step, tool_name: name, duration_ms: Date.now() - started });
stats.toolCalls.push({ name, ms: Date.now() - started, ok: !(result && result.error) });
stats.toolResults.push(result);
@@ -512,13 +520,16 @@ function createAgent(cfg, deps = {}) {
audit.uncovered_entities = citation.uncovered_entities;
audit.lead_followup = leadFollowup;
audit.output_guard = { blocked: guard.blocked, rewritten: guard.rewritten, reasons: guard.reasons, removed_urls: guard.removed_urls, evidence_missing: guard.evidence_missing };
// 分阶段耗时(对齐旧 bridge 的 llmMs/toolMs):写入 audit(jsonb,无 DDL)并打一行日志
audit.timing = { llm_ms: stats.llmMs, tool_ms: stats.toolMs };
const latencyMs = Date.now() - startedAt;
await metrics.record({
conversationUuid: conv.uuid,
inboxId: conv.inbox_id,
channel: conv.channel || 'livechat',
verified: ctx.verified,
model: llm.model,
latencyMs: Date.now() - startedAt,
latencyMs,
llmCalls: stats.llmCalls,
promptTokens: stats.promptTokens,
completionTokens: stats.completionTokens,
@@ -529,6 +540,17 @@ function createAgent(cfg, deps = {}) {
});
prom.inc('bridge_ai_runs_total');
await traceWriter.writeSteps(traceSteps, traceId, conv.uuid).catch(() => {});
console.log(JSON.stringify({
ts: new Date().toISOString(),
agent: 'metrics',
conversation: conv.uuid,
intent,
latency_ms: latencyMs,
llm_ms: stats.llmMs,
tool_ms: stats.toolMs,
llm_calls: stats.llmCalls,
reply_length: finalContent.length,
}));
console.log(JSON.stringify({ ts: new Date().toISOString(), agent: 'replied', conversation: conv.uuid, length: finalContent.length, audit }));
return finalContent;
}
+70 -1
View File
@@ -39,4 +39,73 @@ function translateProductQuery(query, entries) {
};
}
module.exports = { translateProductQuery, normalize };
// ---- 商品关键词清洗(对齐旧 bridge:不把客户整句原文丢给商品 RPC)----
// 商品 RPC(/product/list、/product_new/autocomplete)对含数量/货币的长句极慢
// (实测 "500 stainless steel rings wholesale" 18s vs "stainless steel rings" 0.6s),
// 且结果会跑偏,所以调用前只保留 2-4 个商品/类目词。
const STOPWORDS = new Set([
'the', 'a', 'an', 'and', 'or', 'for', 'to', 'of', 'in', 'on', 'with', 'at', 'by',
'do', 'does', 'did', 'is', 'are', 'was', 'be', 'am', 'how', 'what', 'which', 'who',
'can', 'could', 'would', 'should', 'you', 'your', 'yours', 'my', 'me', 'i', 'we', 'us', 'it', 'its',
'hi', 'hello', 'hey', 'please', 'thanks', 'thank', 'there', 'this', 'that', 'these', 'those',
'have', 'has', 'had', 'interested', 'interest', 'any', 'some', 'also', 'about', 'from', 'get',
]);
const TRANSACTION_WORDS = new Set([
'buy', 'buying', 'purchase', 'purchasing', 'order', 'orders', 'ordering', 'wholesale', 'wholesaler',
'bulk', 'supplier', 'suppliers', 'price', 'prices', 'pricing', 'cost', 'costs', 'cheap', 'cheapest',
'deal', 'deals', 'discount', 'discounts', 'qty', 'quantity', 'quantities', 'moq', 'quote', 'quotation',
'sample', 'samples', 'bestseller', 'bestsellers', 'trending', 'popular', 'hot', 'sale', 'catalog',
'catalogue', 'browse', 'recommend', 'recommendation', 'recommendations', 'available', 'availability',
'stock', 'under', 'below', 'above', 'over', 'between', 'less', 'more', 'than', 'each', 'per',
'min', 'max', 'minimum', 'maximum', 'fast', 'delivery', 'shipping', 'ship', 'send', 'need', 'needed',
'want', 'wanted', 'wanting', 'looking', 'find', 'search', 'show', 'tell', 'give', 'see', 'list',
'good', 'best', 'top', 'new', 'sale', 'fashion', 'style', 'cheapest', 'right', 'now',
// 售后/流程/联系类词:出现在商品问题里也不是商品词,留着只会拖慢商品 RPC
'return', 'returns', 'returned', 'exchange', 'exchanges', 'refund', 'refunds', 'policy', 'warranty',
'question', 'questions', 'ask', 'asking', 'inquiry', 'inquiries', 'enquiry', 'within', 'before',
'after', 'day', 'days', 'week', 'weeks', 'month', 'months', 'year', 'years', 'accept', 'accepting',
'provide', 'providing', 'offer', 'offering', 'allow', 'allowed', 'possible', 'support', 'service',
'services', 'customer', 'customers', 'contact', 'email', 'phone', 'address',
]);
const UNITS = new Set([
'pc', 'pcs', 'piece', 'pieces', 'unit', 'units', 'item', 'items', 'set', 'sets', 'pair', 'pairs',
'kg', 'kgs', 'kilo', 'kilos', 'gram', 'grams', 'g', 'ml', 'liter', 'liters', 'litre', 'litres',
'cm', 'mm', 'inch', 'inches', 'ft', 'box', 'boxes', 'carton', 'cartons', 'lot', 'lots',
'thousand', 'dozen', 'pack', 'packs', 'pcs.', '件', '条', '个', '只', '套', '对', '双', '箱', '打', '批', '台', '张',
]);
const MONEY = new Set([
'dollar', 'dollars', 'usd', 'eur', 'euro', 'euros', 'cent', 'cents', 'yuan', 'rmb', 'cny',
'元', '美元', '欧元', '人民币',
]);
// 数量/规格 token:500、10.5、500pcs、10cm、18k
const QUANTITY_TOKEN_RE = /^\d+(?:[.,]\d+)?(?:k|pcs|pc|pieces?|units?|sets?|pairs?|kg|g|cm|mm|ml|l|inch(?:es)?|ft)?$/;
const MIN_KEEP = 2;
const MAX_KEEP = 4;
/**
* 从客户原话里提取用于商品 RPC 的短关键词。
* 中文先交给 translateProductQuery 映射类目;没有可用词时返回空串。
* @param {string} value 客户原文(或已翻译的类目名)
* @returns {string}
*/
function extractProductKeyword(value) {
const q = normalize(value);
if (!q) return '';
const kept = [];
for (const token of q.split(/\s+/)) {
if (!token) continue;
if (QUANTITY_TOKEN_RE.test(token)) continue;
if (UNITS.has(token) || MONEY.has(token) || TRANSACTION_WORDS.has(token) || STOPWORDS.has(token)) continue;
if (token.length < MIN_KEEP && !CJK.test(token)) continue;
kept.push(token);
if (kept.length >= MAX_KEEP) break;
}
// 纯数量/纯交易词(如 "500 pcs wholesale")剥完是空的 → 交给上层决定是否退回原文
return kept.join(' ');
}
module.exports = { translateProductQuery, extractProductKeyword, normalize };
+7 -3
View File
@@ -6,7 +6,7 @@ const { ToolError } = require('./errors');
const { createTwentyClient } = require('./twenty');
const { asList, asObject } = require('./rpc');
const { createLeadStore } = require('./leads');
const { translateProductQuery } = require('./product-query');
const { translateProductQuery, extractProductKeyword } = require('./product-query');
const { createSiteMap } = require('./sitemap');
const { createOpportunityService } = require('./opportunity');
const { createMailHistory } = require('./mail-history');
@@ -406,7 +406,9 @@ function createTools(cfg, deps = {}) {
const profile = resolveSiteProfile(cfg, ctx);
// 中文/别名商品词先映射到英文类目,例如 简约不锈钢戒指 -> Rings
const translation = translateProductQuery(query, siteEntries(profile));
const rpcQuery = translation.query || query;
// 商品 RPC 对含数量/货币的长句极慢(实测 18s vs 0.6s),只把清洗后的 2-4 个商品词发给 RPC;
// 清洗为空(纯数量/纯交易词)时退回原 query,保持原有兜底行为。
const rpcQuery = extractProductKeyword(translation.query || query) || translation.query || query;
const absoluteUrl = (href) => {
if (!href) return null;
@@ -510,7 +512,9 @@ function createTools(cfg, deps = {}) {
...(total != null ? { total } : {}),
source,
site: profile.site,
...(translation.translated ? { query_used: rpcQuery, translated_from: query, category: translation.category } : {}),
// 实际发给商品 RPC 的关键词(清洗后),便于测试服核对;原话仍留在 search_url
query_used: rpcQuery,
...(translation.translated ? { translated_from: query, category: translation.category } : {}),
...(categoryLinks.length && source === 'catalog' ? { category_links: categoryLinks } : {}),
search_url: profile.searchUrl + encodeURIComponent(query),
...(source === 'site-map'
+38
View File
@@ -357,3 +357,41 @@ test('agent: extracts lead fields when the LLM skips collect_info (company menti
assert.equal(twenty.calls.filter((c) => c[0] === 'createOpportunity').length, 1);
assert.ok(twenty.calls.some((c) => c[0] === 'createCompany' && c[1] === 'Leadfix GmbH'));
});
test('agent: product pre-retrieval sends cleaned keyword instead of the full sentence', async () => {
const queries = [];
const tools = [{
name: 'search_products',
description: 'fake',
parameters: { type: 'object', properties: {} },
handler: async (args) => {
queries.push(args.query);
return { products: [{ name: 'Steel Ring', price: '€2,50', stock: 10, url: 'https://www.yehwang.com/p/steel-ring.html' }], count: 1 };
},
}];
tools.leadStore = { get: async () => null, save: async () => null };
let sent = '';
let recorded = null;
const agent = createAgent({ sites: [], defaultSite: 'www' }, {
llm: {
enabled: true,
model: 'fake',
chat: async () => ({ content: 'Steel Ring — €2,50,库存 10 件。', toolCalls: [], usage: null }),
},
client: {
getMessages: async () => [{ id: 1, text_content: 'We need 500 stainless steel rings wholesale.', sender_type: 'contact', private: false }],
sendMessage: async (_uuid, content) => { sent = content; },
sendPrivateNote: async () => {},
},
tools,
verification: { getVerifiedEmail: async () => null, sendCode: async () => ({}), verifyCode: async () => ({}) },
metrics: { record: async (m) => { recorded = m; }, enabled: false },
contextManager: { compressHistory: async (_c, h) => h },
});
const conv = { uuid: 'conv-clean-1', inbox_id: '1', channel: 'livechat', contact: { type: 'visitor' } };
await agent.run(conv, { uuid: 'msg-clean-1', text_content: 'We need 500 stainless steel rings wholesale.', content: '...' });
assert.deepEqual(queries, ['stainless steel rings']);
assert.match(sent, /Steel Ring/);
assert.equal(typeof recorded.audit.timing.llm_ms, 'number');
assert.equal(typeof recorded.audit.timing.tool_ms, 'number');
});
+18 -1
View File
@@ -2,7 +2,7 @@
const test = require('node:test');
const assert = require('node:assert/strict');
const { translateProductQuery } = require('../src/product-query');
const { translateProductQuery, extractProductKeyword } = require('../src/product-query');
test('translateProductQuery: Chinese category alias -> English title', () => {
const entries = [
@@ -20,3 +20,20 @@ test('translateProductQuery: English query unchanged', () => {
assert.equal(out.translated, false);
assert.equal(out.query, 'hoop earrings');
});
test('extractProductKeyword: drops quantities/currency/transaction words', () => {
assert.equal(extractProductKeyword('We need 500 stainless steel rings wholesale under 10 dollars'), 'stainless steel rings');
assert.equal(extractProductKeyword('500 rings'), 'rings');
assert.equal(extractProductKeyword('do you have gold hoop earrings?'), 'gold hoop earrings');
assert.equal(extractProductKeyword('do you accept returns on stainless steel rings'), 'stainless steel rings');
});
test('extractProductKeyword: caps at 4 tokens, keeps CJK', () => {
assert.equal(extractProductKeyword('neo stainless steel rings 500 pcs'), 'neo stainless steel rings');
assert.equal(extractProductKeyword('简约不锈钢戒指'), '简约不锈钢戒指');
});
test('extractProductKeyword: pure quantity/transaction returns empty', () => {
assert.equal(extractProductKeyword('500 pcs wholesale'), '');
assert.equal(extractProductKeyword(''), '');
});
+12
View File
@@ -91,6 +91,18 @@ test('search_products: maps RPC items and caps results', async () => {
assert.equal(rpc.calls[0].method, '/product/list');
});
test('search_products: sends cleaned short keyword to catalog RPC for long queries', async () => {
const rpc = fakeRpc(() => ({ data: { list: [{ spuName: 'Steel Ring', minPriceWithCurrency: '€2,50', spuId: 1 }] } }));
const tools = createTools(baseCfg, { rpc, twenty: fakeTwenty() });
const tool = tools.find((t) => t.name === 'search_products');
const out = await tool.handler({ query: 'We need 500 stainless steel rings wholesale under 10 dollars', limit: 3 }, { verified: false });
const cmd = rpc.calls[0].params[0];
assert.equal(cmd.keyword, 'stainless steel rings');
assert.equal(out.query_used, 'stainless steel rings');
// 给客户看的兜底搜索链接仍用原话
assert.match(out.search_url, /500%20stainless%20steel%20rings/);
});
test('search_products: falls back to site map when catalog returns empty', async () => {
const tools = createTools(baseCfg, {
rpc: fakeRpc(() => []),