意图识别嘛,把用户的话丢给大模型,让它输出一个分类 JSON 不就行了?能跑,但是实际企业里面用起来,这个"纯 LLM"方案有三个致命成本: 1. 慢。一次 LLM 分类动辄 300ms~2s,而"你好""查一下审批进度"这种高频、模板化的话,本不该为一句问候付一次大模型的延迟。 2. 贵。每一句都过一次大模型,token 成本随流量线性增长,其中相当大比例是"闭眼都能判对"的简单意图。 3. 不稳定。LLM 偶发漂移,同一句话今天判 A 明天判 B,难以回归测试。 gogo-agent 的解法是把意图识别做成一条短路流水线:先用最便宜、最快、最确定的手段试,试不出来再逐级升级到更贵但更聪明的手段。这就是"三层": | 层 | 手段 | 命中场景 | 目标延迟 | 是否调用大模型 | | --- | --- | --- | --- | --- | | L1 | 规则 / 关键词(正则) | 高频、模板化、表达清晰("报销""你好""查审批") | < 50ms | 否 | | L2 | 向量相似度(RAG 检索) | 口语化、换了说法但语义清晰("垫付的钱帮我弄回来") | < 100ms | 否(只做 embedding) | | L3 | LLM 兜底 | 模糊、多意图、跨领域、信息不足 | 数百 ms~数 s | 是 |
设计哲学一句话:L1 求快、L2 求全、L3 求准。前两层能拦下的绝不惊动大模型;只有真正含糊的问题才交给 L3。整套机制上线后可以通过日志观察各层命中率,持续把 L3 的流量往 L1/L2 迁。 贯穿三层还有一个关键约束——三层的输出必须同构。无论哪层命中,最终都产出同一个 JSON schema(intents / primary_intent / multi_intent / overall_reason),这样下游的调度逻辑(MasterAgent 路由、子智能体直跳)完全不需要知道"这次是第几层判的"。 这个同构就落在 IntentRecognitionResult 上。它是 L1/L2/L3 共同的产物类型,也是下游调度唯一认识的数据结构。
public class IntentRecognitionResult {
public enum Source { RULE, VECTOR, LLM } // 哪一层命中(仅用于排障/日志)
public enum Confidence {
HIGH, MEDIUM, LOW;
public String wireValue() { return name().toLowerCase(); } // 序列化为 high/medium/low
public static Confidence fromWire(String v) { /* high→HIGH, 其余→LOW */ }
}
// 单个意图项:类别 + 目标 agent + 置信度 + 理由
public static class IntentItem {
private final IntentCategory category;
private final String targetAgent;
private final Confidence confidence;
private final String reason;
public Map<String, Object> toMap() {
return Map.of(
"intent", getIntent(), // 意图 code
"target_agent", targetAgent == null ? "" : targetAgent,
"confidence", confidence.wireValue(),
"reason", reason == null ? "" : reason);
}
}
private final Source source;
private final List<IntentItem> intents;
private final IntentCategory primary;
private final boolean multiIntent;
private final String overallReason;
private final Double score; // L1/L2 命中时的分数(向量相似度等),L3 为 null
// 便捷工厂:单意图结果(L1/L2 只会产出单意图)
public static IntentRecognitionResult single(Source source, IntentCategory category,
Confidence confidence, String reason, Double score) {
String target = (category == null ? IntentCategory.UNKNOWN : category).getDefaultTargetAgent();
IntentItem item = new IntentItem(category, target, confidence, reason);
return new IntentRecognitionResult(source, List.of(item),
category, false /* multiIntent */, reason, score);
}
// 关键:输出与 LLM 同构的 JSON Map,塞进下游 agent 的上下文
public Map<String, Object> toJsonMap() {
return Map.of(
"intents", intents.stream().map(IntentItem::toMap).toList(),
"primary_intent", getPrimaryIntent(),
"multi_intent", multiIntent,
"overall_reason", overallReason);
}
}
意图分类的"字典":IntentCategory
在讲三层之前,先看清楚"意图"到底有哪些、每种意图该路由到哪个子智能体。这是整个体系的公共词表,L1/L2/L3 都引用它。
public enum IntentCategory {
// code(路由 JSON 用的标识) / defaultTargetAgent(目标子智能体 bean 名) / description(中文说明)
TRAVEL_APPLICATION("travel_application", "ItineraryManageAgent", "用户要提交新的差旅申请/出差审批"),
TRAVEL_CANCEL ("travel_cancel", "ItineraryManageAgent", "用户要取消出差申请或审批单"),
TRAVEL_MODIFY ("travel_modify", "ItineraryManageAgent", "用户要修改差旅申请信息"),
APPROVAL_QUERY ("approval_query", "ItineraryManageAgent", "用户查询审批进度/状态/结果"),
TRAVEL_ORDER_QUERY("travel_order_query", "ItineraryManageAgent", "用户查询已有差旅单详情/状态"),
ITINERARY_PLANNING("itinerary_planning", "ItineraryPlanAgent", "用户要求规划行程、做方案"),
FLIGHT_SEARCH ("flight_search", "ItineraryPlanAgent", "用户要查航班"),
TRAIN_SEARCH ("train_search", "ItineraryPlanAgent", "用户要查火车"),
HOTEL_SEARCH ("hotel_search", "ItineraryPlanAgent", "用户要查酒店"),
BOOKING ("booking", "BookingAgent", "用户要预订/改签/取消已选方案"),
REIMBURSEMENT ("reimbursement", "ReimbursementAgent", "用户要报销、识别发票、生成报销单"),
POLICY_QUERY ("policy_query", "InfoAgent", "用户查询差旅政策/餐标/酒店标准/签证入境政策"),
ATTRACTIONS_QUERY ("attractions_query", "InfoAgent", "用户查询目的地景点、旅游信息"),
GENERAL_INFO ("general_info", "InfoAgent", "天气/地图/交通/目的地新闻等通用信息查询"),
GREETING ("greeting", "MasterAgent", "用户打招呼、寒暄"),
UNKNOWN ("unknown", "MasterAgent", "无法明确分类或信息严重不足");
// 根据 code 反查枚举;查不到一律返回 UNKNOWN(对未知输入保持健壮)
public static IntentCategory fromCode(String code) {
if (code == null || code.isBlank()) return UNKNOWN;
for (IntentCategory c : values()) {
if (c.code.equals(code)) return c;
}
return UNKNOWN;
}
}
这里有三个值得注意的设计: 一是每个意图自带 defaultTargetAgent,意图识别的产物天然携带"该去哪",路由层无需再维护一张 intent → agent 映射表。16 个意图收敛到 5 个子智能体:差旅单管理(ItineraryManageAgent)、行程规划(ItineraryPlanAgent)、预订(BookingAgent)、报销(ReimbursementAgent)、信息问答(InfoAgent),外加兜底的 MasterAgent。 二是枚举与 L3 的系统提示词一一对应。prompts/intent-recognition-agent-system.md 里定义的意图类别,和这个枚举严格对齐——这样 L3 LLM 吐出来的 code,用 fromCode() 一定能落到某个枚举上,落不上就是 UNKNOWN,绝不会抛异常。 三是 fromCode 的兜底语义:任何不认识的 code 都归 UNKNOWN → MasterAgent。这让"新加意图但 LLM 提前学会了""LLM 手滑输出错别字"这类情况都能安全降级,而不是让流水线崩掉。
L1:规则 / 关键词匹配(求快)
L1 是最外层的过滤网,目标是用纯内存正则在 50ms 内拦下所有"闭眼可判"的高频表达。核心是 IntentRuleMatcher。
规则的数据结构:关键词 + 排除词
@Component
public class IntentRuleMatcher {
// 单条规则:keyword 命中"且"不含任一 negativeKeyword,才算命中
private static final class Rule {
final IntentCategory category;
final Pattern keyword; // 正向关键词(正则)
final List<Pattern> negativeKeywords; // 排除词(用于剔除跨类干扰)
boolean matches(String text) {
if (!keyword.matcher(text).find()) return false; // 正向没命中,直接否
for (Pattern negative : negativeKeywords) {
if (negative.matcher(text).find()) return false; // 命中任一排除词,否
}
return true;
}
}
private final List<Rule> rules = new ArrayList<>(); // List 顺序 == 优先级,命中即短路
}
关键点在 negativeKeywords(排除词)。仅靠正向关键词会误判——比如"报销"和"报销政策"字面高度重叠,但前者是要真报销(→ ReimbursementAgent),后者只是问制度(→ InfoAgent)。排除词就是用来划清这类跨类边界的:命中正向词,且不命中任何排除词,才算数。
规则集:顺序即优先级
所有规则在构造函数里按优先级顺序 addRule,List 的先后就是求值顺序,命中即短路。这里摘几条最能体现设计取舍的规则:
public IntentRuleMatcher() {
// ① 寒暄:整句"只由问候语构成"才命中。
// 这样"下午好,请问在吗?"命中寒暄,而"你好,帮我订机票"不会被误判成寒暄。
String greet = "你好|您好|哈喽|嗨|hi|hello|早上好|下午好|晚上好|在吗|在不在|请问|打扰一下…";
String sep = "[\\s,,。.!!??~~、]*";
addRule(IntentCategory.GREETING,
"^(?:" + greet + ")(?:" + sep + "(?:" + greet + "))*" + sep + "$"); // ^…$ 锚定整句
// ② 报销:动作性极强,优先级高;但排除"报销政策/标准/能不能报"等问制度的说法
addRule(IntentCategory.REIMBURSEMENT,
"(报销|报账|贴票|发票|报销单|差旅报销|提交报销|报一下|帮我报|电子发票|机票行程单)",
"政策", "标准", "规定", "额度", "能不能报", "报销吗", "怎么报", "报销范围");
// ③ 政策/标准:覆盖企业术语(差标、超标、餐标、舱位标准…)
addRule(IntentCategory.POLICY_QUERY,
"(差旅政策|差标|超标|餐标|住宿标准|舱位标准|报销标准|报销政策|签证|入境政策|出差规定…)");
// … 审批查询、取消、修改 …
// ④ 行程规划:必须排在 FLIGHT/TRAIN/HOTEL_SEARCH 之前,
// 优先捕获"规划+行程/方案",避免"帮我规划去杭州的行程"被机票规则抢走
addRule(IntentCategory.ITINERARY_PLANNING,
"(规划.*行程|安排.*行程|行程规划|出行方案|帮我规划|规划一下|帮我安排一下)");
// ⑤ 机票查询:排除发票/报销/标准/政策,以及取消/退票/改签(后者归 BOOKING)
addRule(IntentCategory.FLIGHT_SEARCH,
"(查机票|订机票|机票|航班|飞机票|头等舱|经济舱|往返机票|直飞…)",
"发票", "报销", "标准", "政策", "取消", "退票", "改签");
// ⑥ 通用兜底:预订/改签/退票,放在具体机/酒/火之后,避免抢占更具体的查询意图
addRule(IntentCategory.BOOKING,
"(预订|下单|订这个|就订(这个|它)|确认(预订|下单)|改签|退票|退订|取消(预订|订单|机票|酒店|火车票))");
// ⑦ 新建差旅申请:放最后,避免抢占查询/规划/修改类;排除审批状态查询与取消/查询/规划/报销
addRule(IntentCategory.TRAVEL_APPLICATION,
"(申请出差|出差申请|发起(差旅|出差)|我要出差|(下周|下个月|明天).*出差|新建(差旅|出差)|报备出差…)",
"审批进度", "审批状态", "取消", "查", "规划", "报销");
}
从这些规则能读出 L1 的三条设计原则: 其一,用 ^…$ 锚定整句来收紧寒暄。 寒暄是最容易"误吞"其他意图的类别(几乎每句话开头都可能带"你好"),所以它的正则要求整句从头到尾只由问候语(可多段叠加、可带标点)构成,"你好,帮我订机票"里的"你好"不会让整条被判成寒暄。 其二,顺序编排消歧。 把"具体动作类(报销/政策/审批/取消/修改)"排在"泛化查询/预订类"之前,把"行程规划"排在"机票/火车/酒店查询"之前,把"通用预订兜底"和"新建差旅申请"放最后。这样当一句话同时能匹配多条规则时,更具体、更明确的意图先命中。 其三,宁可漏判不可误判。 L1 命中会直接短路、跳过后面的问题改写和 L2/L3,所以它必须保守——不确定就放行,交给下游更聪明的层。这也是为什么信息不足的开放式问法("随便问问""这个怎么说")在 L1 里刻意不写规则。
匹配入口与返回
public Optional<IntentRecognitionResult> match(String text) {
if (text == null || text.isBlank()) return Optional.empty();
String normalized = text.trim();
for (Rule rule : rules) { // 按优先级顺序求值
if (rule.matches(normalized)) {
return Optional.of(IntentRecognitionResult.single(
IntentRecognitionResult.Source.RULE, // 标记来源:L1 规则
rule.category,
IntentRecognitionResult.Confidence.HIGH, // 规则命中一律高置信
"L1 规则命中:关键词匹配到「" + rule.category.getDescription() + "」",
null)); // 规则命中无相似度分数
}
}
return Optional.empty(); // 全部未命中 → 交给 L2
}
// 正则统一大小写不敏感 + Unicode,兼容中英文混排
private static Pattern compile(String regex) {
return Pattern.compile(regex, Pattern.CASE_INSENSITIVE | Pattern.UNICODE_CASE);
}
命中即返回一个 Source.RULE、Confidence.HIGH 的统一结果;一条都不命中就返回 empty,编排器据此下沉到 L2。注意规则命中被固定为高置信度——因为能被精心设计的正则命中的,本就是表达清晰的高频句,高置信是合理的。
L2:向量相似度 / RAG(求全)
L1 只能覆盖"你写进正则里的说法"。可真实用户偏不按套路出牌——他们会说"这趟出差垫付的钱帮我弄回来"(=报销)、"帮我找个离客户近点的落脚地方"(=订酒店)。这些句子里一个 L1 关键词都没有,却语义清晰。L2 就是用来兜住这批"换了说法但意思明确"的表达:把用户问题和一批"意图代表问句"都转成向量,算语义相似度,够像就判定为对应意图。
知识库装配:IntentRouterKnowledgeConfig
L2 依赖 AgentScope 的 RAG 能力:一个 EmbeddingModel(把文本转向量)+ 一个 Knowledge(存向量、做检索)。它们在 IntentRouterKnowledgeConfig 里被显式装配成 Spring bean:
@Configuration
public class IntentRouterKnowledgeConfig {
private static final int EMBEDDING_DIM = 1024; // text-embedding-v4 输出 1024 维
@Autowired
private IntentSeedExamplesProperties seedExamplesProperties;
private final Map<IntentCategory, List<String>> categoryExamples = new LinkedHashMap<>();
// ① 独立的 embedding 实例:与 RAG 景点/政策知识库隔离,避免互相干扰
@Bean(name = "intentRouterEmbeddingModel")
public EmbeddingModel intentRouterEmbeddingModel() {
if (apiKey == null || apiKey.isBlank()) {
throw new IllegalStateException("意图路由向量库要求配置 dashscope api-key");
}
return DashScopeTextEmbedding.builder()
.modelName("text-embedding-v4")
.dimensions(EMBEDDING_DIM)
.apiKey(apiKey)
.build();
}
// ② 意图路由知识库:SimpleKnowledge(内存向量库)+ 启动时预填充种子语料
@Bean(name = "intentRouterKnowledge")
public Knowledge intentRouterKnowledge(EmbeddingModel intentRouterEmbeddingModel) {
InMemoryStore store = InMemoryStore.builder().dimensions(EMBEDDING_DIM).build();
SimpleKnowledge knowledge = SimpleKnowledge.builder()
.embeddingModel(intentRouterEmbeddingModel)
.embeddingStore(store)
.build();
// addDocuments 会触发 embedding 计算,.block() 阻塞等待启动期填充完成
List<Document> docs = buildSeedDocuments();
knowledge.addDocuments(docs).block();
logger.info("[INTENT_ROUTER] 意图路由向量库启动完成,预填充样本数={}", docs.size());
return knowledge;
}
// ③ 显式装配 L2 匹配器(不用 @Component 的原因见下方"多 Knowledge bean"陷阱)
@Bean(name = "intentVectorMatcher")
public IntentVectorMatcher intentVectorMatcher(Knowledge intentRouterKnowledge) {
return new IntentVectorMatcher(intentRouterKnowledge);
}
}
knowledge.addDocuments(docs).block() 在应用启动时同步把所有种子语料算好 embedding 写进内存库。这样运行期 L2 命中只需一次"对用户问题算 embedding + 检索",把延迟压在 100ms 量级(不含网络抖动)。
种子语料怎么变成向量文档:buildSeedDocuments
private List<Document> buildSeedDocuments() {
if (categoryExamples.isEmpty()) initSeedExamples(); // 确保 @PostConstruct 已填充
List<Document> docs = new ArrayList<>();
int chunkSeq = 0;
for (Map.Entry<IntentCategory, List<String>> entry : categoryExamples.entrySet()) {
IntentCategory category = entry.getKey();
for (String example : entry.getValue()) {
// 关键:把"这句话属于哪个意图 / 该去哪个 agent / 置信度"作为 payload 附在向量上
Map<String, Object> payload = new HashMap<>();
payload.put("intent", category.getCode());
payload.put("target_agent", category.getDefaultTargetAgent());
payload.put("confidence", "high");
DocumentMetadata metadata = DocumentMetadata.builder()
.content(TextBlock.builder().text(example).build()) // 被 embedding 的正文
.docId("intent-" + category.getCode())
.chunkId(String.valueOf(chunkSeq++))
.payload(payload) // 检索命中后原样取回
.build();
docs.add(new Document(metadata));
}
}
return docs;
}
思路很直白:每条代表问句 = 一个向量文档,文档的 payload 里挂着它所属的意图 code。检索时用用户问题去找最像的那条文档,读它 payload 里的 intent 就知道该判成什么意图了。这本质是"用向量最近邻做分类"。
种子语料的设计精髓:刻意不与 L1 重叠
种子语料外置在 intent-seed.yml,由 IntentSeedExamplesProperties 绑定到内存:
@Component
@ConfigurationProperties(prefix = "intent.router")
public class IntentSeedExamplesProperties {
// key = IntentCategory 的 code,value = 该意图的高频口语问句
private Map<String, List<String>> seedExamples = new LinkedHashMap<>();
// getter / setter …
}
yml 内容(节选)——请特别注意它的选词:
intent:
router:
seed-examples:
flight_search: # 注意:没有"机票""航班"这些 L1 词
- "明天想坐飞机去上海,有哪些班次可选"
- "后天去深圳,早点起飞的有哪些"
hotel_search: # 用"落脚地方/过夜的地方/住处"代替"酒店"
- "这次去上海出差,帮我找个离客户近点的落脚地方"
- "帮我看看公司附近有没有方便过夜的地方"
reimbursement: # 用"垫付的钱/单据/开销"代替"报销""发票"
- "这趟出差垫付的钱,帮我弄回来"
- "出差花的这些单据,帮我整理好交上去"
travel_application: # 用"走流程/办手续/登记公务"代替"出差申请"
- "下周得去客户现场驻场几天,帮我在系统里走一下流程"
这是 L2 语料设计的核心心法,也是最容易被写错的地方: 所以你看到的是:用"坐飞机/起飞"代替"机票/航班",用"铁路"代替"高铁/火车",用"落脚/过夜的地方"代替"酒店/住宿",用"垫付的钱/单据"代替"报销/发票"。这些正是 L1 模板难以命中、必须靠向量语义兜底的部分。 而且这条"不重叠"约束不是靠人肉自觉,项目里有一个离线测试 IntentRouterSeedExamplesTest#shouldNotOverlapWithL1Rules 做冗余护栏——一旦有人往语料里塞了会被 L1 命中的句子,测试就会红,从机制上保证 L1/L2 的分工不被破坏。
检索与阈值判定:IntentVectorMatcher
public class IntentVectorMatcher {
public static final double DEFAULT_SCORE_THRESHOLD = 0.75; // 相似度阈值
private static final int TOP_K = 1; // 只取最像的 1 条
private final Knowledge knowledge;
private double scoreThreshold;
public Optional<IntentRecognitionResult> match(String text) {
if (text == null || text.isBlank()) return Optional.empty();
String normalized = text.trim();
// 先取 top1,scoreThreshold 传 0 让向量库全返回,由我们自己拿原始 score 做阈值判断
RetrieveConfig config = RetrieveConfig.builder().limit(TOP_K).scoreThreshold(0.0).build();
List<Document> docs;
try {
docs = knowledge.retrieve(normalized, config).block();
} catch (Exception e) {
// 任何 embedding/检索失败都视为未命中,让 L3 兜底,绝不阻塞主流程
logger.warn("[INTENT_ROUTER] L2 向量检索失败,降级到 L3: {}", e.getMessage());
return Optional.empty();
}
if (docs == null || docs.isEmpty()) return Optional.empty();
Document top = docs.get(0);
Double score = top.getScore();
if (score == null || score < scoreThreshold) { // 不够像 → 放行到 L3
return Optional.empty();
}
Object intentCode = top.getPayloadValue("intent"); // 读回 payload 里的意图 code
if (intentCode == null) return Optional.empty();
IntentCategory category = IntentCategory.fromCode(intentCode.toString());
// 相似度分数进一步映射到置信度等级
Confidence conf = classify(score);
return Optional.of(IntentRecognitionResult.single(
IntentRecognitionResult.Source.VECTOR, category, conf,
"L2 向量命中:相似度=" + String.format("%.3f", score)
+ ",匹配样本「" + top.getMetadata().getContentText() + "」",
score));
}
// 相似度越高,置信度越高
private static ConfidenceFromScore classify(double score) {
if (score >= 0.85) return new ConfidenceFromScore(Confidence.HIGH);
if (score >= 0.75) return new ConfidenceFromScore(Confidence.MEDIUM);
return new ConfidenceFromScore(Confidence.LOW);
}
}
几个关键决策: - TOP_K = 1:L2 的目标是"比 L3 快一个量级",取一条最像的即可,不做复杂的多路投票,省得比 L3 还慢。 - 阈值自己判,而不是交给向量库:检索时把 scoreThreshold 传 0(全返回),拿到原始 score 后由本类自己比对 DEFAULT_SCORE_THRESHOLD = 0.75。这样能拿到真实分数用于日志排障,也方便把"够不够像"的策略集中在 Java 侧。 - 分数 → 置信度分级:≥0.85 高、≥0.75 中、否则低。这个置信度会影响下游是否敢"直跳子智能体" - 失败即降级:embedding 服务抖动、网络超时、返回空……任何异常都 catch 成 empty,绝不让 L2 的问题阻断主流程,统一由 L3 兜底。阈值默认 0.75 偏低是为了减少漏判(宁可多放一些给 L2 判,实在不像再落 L3)。
L3:LLM 兜底 + 三层编排
编排器:IntentRecognitionRouter(L1 → L2)
IntentRecognitionRouter 负责把 L1、L2 串成短路流水线,并不自己调 L3——L3 由调用方在拿到 empty 时兜底。
@Component
public class IntentRecognitionRouter {
private static final long L1_TARGET_MS = 50; // 软目标,仅用于日志告警
private static final long L2_TARGET_MS = 100;
@Autowired private IntentRuleMatcher ruleMatcher;
@Autowired private IntentVectorMatcher vectorMatcher;
public Optional<IntentRecognitionResult> route(String question) {
if (question == null || question.isBlank()) return Optional.empty();
String normalized = question.trim();
// -------- L1 --------
long l1Start = System.nanoTime();
Optional<IntentRecognitionResult> ruleHit = ruleMatcher.match(normalized);
long l1Ms = (System.nanoTime() - l1Start) / 1_000_000L;
if (ruleHit.isPresent()) {
logResult("L1", l1Ms, ruleHit.get(), true);
warnIfSlow("L1", l1Ms, L1_TARGET_MS); // 超过 50ms 打告警
return ruleHit; // 命中即短路
}
// -------- L2 --------
long l2Start = System.nanoTime();
Optional<IntentRecognitionResult> vectorHit;
try {
vectorHit = vectorMatcher.match(normalized);
} catch (Exception e) {
vectorHit = Optional.empty(); // L2 异常 → 降级 L3
logger.warn("[INTENT_ROUTER] L2 异常,降级到 L3: {}", e.getMessage());
}
long l2Ms = (System.nanoTime() - l2Start) / 1_000_000L;
if (vectorHit.isPresent()) {
logResult("L2", l2Ms, vectorHit.get(), true);
warnIfSlow("L2", l2Ms, L2_TARGET_MS);
return vectorHit;
}
// L1/L2 都没命中 → 返回 empty,调用方负责 L3 兜底
logger.info("[INTENT_ROUTER] L1/L2 miss, will fall through to L3 (q='{}', L1={}ms, L2={}ms)",
truncate(normalized), l1Ms, l2Ms);
return Optional.empty();
}
}
它做的事很纯粹:依次试 L1、L2,命中即短路返回;每层都埋点计时(超过软目标就打 warn,方便上线后监控 embedding/规则的性能退化);都没命中就返回 empty,把"是否升级到 L3"的决定权交给调用方。这种"编排器不管 L3"的设计,是为了让 L3(要调大模型、要挂 Hook、要走 AgentScope 的 Agent 生命周期)留在真正的 Agent 里,编排器保持轻量、可单测。
智能体:IntentRecognitionAgent(L1-L2-L3)
IntentRecognitionAgent 继承 AgentScope 的 AgentBase,把"三层短路 + L3 LLM"封装成一个标准 Agent。它同时扮演"工厂配置"和"Agent 实现"两个角色:
@Component("intentRecognitionAgent")
@Scope("prototype") // 每个 session 拿独立实例,中断状态互不污染
public class IntentRecognitionAgent extends AgentBase {
private final Model strongModel; // L3 用的大模型
private final IntentRecognitionRouter router; // L1/L2 编排器
public IntentRecognitionAgent(@Qualifier("strongModel") Model strongModel,IntentRecognitionRouter router,/* 各类 Hook … */) {
super(NAME, "差旅对话意图识别与路由决策(L1/L2/L3 三层)", true,
List.of(executionLoggerHook, progressNotifierHook,
sessionPersistenceHook, activeAgentPersistenceHook));
this.strongModel = strongModel;
this.router = router;
}
@Override
protected Mono<Msg> doCall(List<Msg> msgs) {
String question = extractLatestUserText(msgs);
if (question != null && !question.isBlank()) {
Optional<IntentRecognitionResult> hit = router.route(question); // 先试 L1/L2
if (hit.isPresent()) {
// L1/L2 命中:直接把统一结果序列化成 JSON 返回,完全绕过大模型
String json = JSON.toJSONString(hit.get().toJsonMap());
return Mono.just(Msg.builder().name(NAME).role(MsgRole.ASSISTANT)
.content(TextBlock.builder().text(json).build()).build());
}
}
// L1/L2 未命中 → L3 LLM 兜底
return callLlmFallback(msgs);
}
// L3:意图识别是"单轮、无工具、maxIters=1",不必套 ReActAgent,直接 Model.stream 一次
private Mono<Msg> callLlmFallback(List<Msg> msgs) {
List<Msg> messages = new ArrayList<>();
messages.add(Msg.builder().role(MsgRole.SYSTEM).name("system")
.content(TextBlock.builder()
.text(PromptLoader.load("intent-recognition-agent-system.md")) // 分类提示词
.build())
.build());
if (msgs != null) messages.addAll(msgs);
return Mono.fromCallable(() -> strongModel.stream(messages, null, null).collectList().block())
.map(responses -> Msg.builder().name(NAME).role(MsgRole.ASSISTANT)
.content(TextBlock.builder().text(extractText(responses)).build()).build());
}
}
三个设计亮点: 其一,L3 不套 ReActAgent。
✅为什么问题改写/意图识别不用ReAct Agent?
问题改写和意图识别,我们是需要借助大模型的,依靠他的语言理解能力和总结能力帮我们做意图识别和问题改写。但是这两个工作我们没有使用ReAct Agent,而是选择了其他的方式实现。这也是有意为之的。 (除了这两个Agent,其实还有标题生成、 LLMentor 其二,@Scope("prototype")。 每个会话拿到独立的 Agent 实例,避免并发会话之间的中断状态互相污染。 其三,doCall 里再次调用 router.route()。 你可能疑惑:编排器只做 L1/L2,那 Agent 的 doCall 岂不是又把 L1/L2 走了一遍?是的——但这是有意的分层:编排器可以被"快路径"单独调用,而 Agent 是"完整路径"的入口,它先复用编排器试 L1/L2,不中才落 L3。主要是因为如果L1/L2不命中,我们会走问题改写,问题改写后的问题,很大概率能重新命中L1/L2,所以这里的Agent中要把L1/L2在走一遍。(这个流程后面讲)
L3 的分类提示词:intent-recognition-agent-system.md
L3 的"聪明"全靠这份系统提示词。它给大模型定了几条硬规矩: - 输出严格 JSON、禁止任何自然语言解释、禁止调用工具,schema 与 toJsonMap() 完全一致(这是三层同构的另一半保障)。 - target_agent 必须是 camelCase 的 Spring bean 名(itineraryManageAgent、infoAgent…),因为下游会直接 context.getBean(target_agent),命名错了就路由失败。 - 多意图要全部识别并按依赖顺序排列(如"先申请后规划、先规划后预订"),并在 primary_intent 里点出最紧迫的那个。 - reason/overall_reason 面向用户展示,严禁泄露内部名词(工具函数名、bean 名、字段名、文件路径),必须用自然中文描述分类理由。 - 低置信度处理:拿不准就标 low 或直接 unknown → masterAgent,交给 MasterAgent 追问确认。 提示词里还带了两个 few-shot 示例(单意图差旅申请、多意图"审批通过后规划+订酒店"),把输出格式钉死。