第6章 意图识别与策略树:消息如何流转
不用模型的意图分类,和把跨领域流程编成"链"的艺术
6.1 本章导读
第4、5章深入了 Agent 引擎内部,但我们跳过了一段:一条 HTTP 请求到达后、进入 ReactLoopAgent 之前,发生了什么?答案在 case 模块——用例编排层。本章讲两个关键设计:意图识别(怎么判断该闲聊还是该调工具)和策略树(怎么把多步流程编成一条可扩展的链)。
6.2 意图识别:为什么用正则而不是模型
很多系统的意图识别会再调一次小模型。本项目刻意用纯正则规则(📦 case/agent/node/AgentIntentNode.java),原因很实际:
| 方案 | 延迟 | 成本 | 可解释性 |
|---|---|---|---|
| 正则规则(本项目) | ~0ms | 零 | 每条规则可单测(AgentIntentNodeTest) |
| 小模型分类 | +一次 LLM 往返(数百 ms~秒级) | 每条消息双倍 token | 黑盒,误判难排查 |
四类意图及其判定逻辑(classify() 方法按顺序短路):
| 意图 | 触发示例 | 后续处理 |
|---|---|---|
CHAT | "你好"、"谢谢" | 可经 [no-tools] 前缀跳过工具,直接对话 |
CODE_QUESTION | "这段代码为什么报错 ```...```" | 进入工具路径(可能要读文件) |
TASK_EXECUTION | "帮我列出目录下的文件"、"把 README 翻译成英文" | 注入"先调工具"指令后进入工具路径 |
CLARIFICATION | 空消息 | 请求澄清 |
规则识别天然有边界。本项目曾踩过坑:AgentDispatchNode.buildDirectoryInstruction() 硬编码"列目录必须调用 fs_search",但 fs_search 是 glob 文件搜索(pattern="*" 会递归所有文件),不适合列一级目录——修复方式是改推荐 shell_execute 执行 ls -la。教训:意图规则要配合"工具能力匹配"一起演进,规则本身也要有测试覆盖。
6.3 策略树:责任链的工程化
跨领域流程(如"发消息要经过:解析 Agent → 识别意图 → 分发 → 收集结果")如果写在一个大方法里,会变成难以维护的 if-else 瀑布。本项目用 AbstractStrategyRouter(📦 domain/shared/strategy/)把它编成责任链:
public abstract class AbstractStrategyRouter<I, C, O> implements StrategyHandler<I, C, O> {
// 节点业务逻辑(子类实现):做自己的事
protected abstract O doApply(I requestParameter, C dynamicContext) throws Exception;
// 路由决策(子类实现):下一个节点是谁
public abstract StrategyHandler<I, C, O> getNext(I requestParameter, C dynamicContext) throws Exception;
@Override
public final O apply(I req, C ctx) throws Exception {
O result = doApply(req, ctx);
if (result != null) return result; // 关键:非 null 短路(如 404、出错直接返回)
return router(req, ctx); // 否则交给下一个节点
}
}三个设计要点:
| 要点 | 说明 |
|---|---|
| doApply 与 getNext 分离 | "做什么"和"接下来找谁"是两个独立决策,节点可独立替换、独立测试 |
| 非 null 短路 | 任何节点返回非 null 结果即终止链路——错误、404、提前完成都靠这个机制 |
| DynamicContext 贯穿 | 泛型 C(如 AgentMessageDynamicContext)在节点间传递累积的中间状态,避免层层改方法签名 |
6.4 Agent 消息链:四节点走一遍
以 POST /api/agent/stream 为例,四个节点依次是:
| 节点 | doApply 做什么 | 写入 DynamicContext 的内容 |
|---|---|---|
AgentResolveNode | 按 agentId 找到或创建 Agent 实例,解析模型/Token/cwd | Agent 实例、GenerateOptions |
AgentIntentNode | 正则分类意图(CHAT/CODE_QUESTION/TASK_EXECUTION/CLARIFICATION) | 意图枚举、需注入的指令 |
AgentDispatchNode | 构造 Message,调用 agent.send(msg, NEXT_TURN, true) 唤醒驱动器 | 发送结果 |
AgentCollectNode | 等待 Agent 空闲,从 SessionLog 投影出完整消息列表(含 Tool 消息按 callId 合并) | 最终响应消息列表 |
这个节点曾经只投影 UserMessage 和 AssistantMessage,漏了 ToolCall/ToolResult。后果:前端收到 done 事件重建消息列表时,把 SSE 流式阶段创建的工具卡片清掉了,工具卡永远显示"running"。修复:也投影 ToolCall(status=running)和 ToolResult(status=success/error),并按 callId 合并到同一条 tool 消息。这是第7章 SSE 协议"done 含全量消息"设计的另一半。
6.5 任务提交链:五节点的治理路径
对话链是"快路径"(实时交互),任务链是"慢路径"(先治理后执行)。五个节点(📦 case/task/submit/node/):
PermissionCheckNode 是治理核心,内部两步:assess(tools) 产出权限评估 → decide(profile, assessment) 产出 ApprovalDecisionVO。EnqueueNode 根据决策分流:免审批直接 executeSession(RUNNING),需审批则停留 PENDING_APPROVAL 等待人工批准。这条链的完整审批流转是第11章的主题。
6.6 为什么策略树适合 Agent 系统
想在意图识别后加一个"敏感词过滤"节点?写个新节点插进链里即可,不用动既有代码。
每个节点是天然埋点——在哪个节点短路、耗时多少,都容易统计。
Agent 流程经常"走到一半发现该换路",责任链的短路机制天然适配这种动态决策。
6.7 课程补充:节点工作流的代码骨架与 DynamicContext 流转
本章讲了两条策略树的概念(Agent 消息链 / 任务提交链)和骨架方法 doApply + getNext。但要让节点真正"跑起来",还涉及三个细节:节点怎么被 Spring 容器管理、动态上下文在不同节点间怎么流转、返回 null 继续和返回非 null 短路分别走什么代码路径。这一节把源码骨架完整铺开。
6.7.1 工厂 + 抽象基类:每个策略树都有自己的类型参数
每条策略树都有一个抽象基类,把泛型 固定住,子类只关心业务逻辑:
// Agent 消息链的基类(cases/agent/node/AbstractAgentMessageNode.java)
public abstract class AbstractAgentMessageNode
extends AbstractStrategyRouter<
AgentMessageRequest, // I:入参(DTO)
AgentMessageDynamicContext, // C:动态上下文
AgentMessageResponse> { // O:返回(DTO)
}
// 任务提交链的基类(cases/task/submit/node/AbstractSubmitTaskNode.java)
public abstract class AbstractSubmitTaskNode
extends AbstractStrategyRouter<
SubmitTaskRequest,
SubmitTaskDynamicContext,
SubmitTaskResponse> {}然后每个节点用 @Service("beanName") 注入,工厂按 bean 名取出 root:
// AgentMessageFactory(cases/agent/factory/)
@Service
public class AgentMessageFactory {
@Autowired @Qualifier("agentMessageResolveNode")
private AgentResolveNode rootNode; // 注入根节点
public StrategyHandler
strategyHandler() {
return rootNode; // 暴露为 StrategyHandler
}
public AgentMessageDynamicContext newContext() {
return new AgentMessageDynamicContext(); // 每次请求一个全新 ctx
}
} 注意几个工程细节:
- 每个节点都是 Spring Bean,节点之间通过
@Autowired @Qualifier互相拿引用,不是手工 new。这让节点本身可以注入领域服务(如agentFactory、modelSettingQueryService、permissionPolicyService)。 - StrategyHandler 是 @FunctionalInterface,所以一个节点其实就是一个"业务方法 + 下一个节点引用"的实现——抽象层足够薄。
- ctx 是每次请求新建的,节点无状态(除了 Spring 注入的依赖),天然线程安全。
6.7.2 DynamicContext 字段流转:实际写的是什么
每个节点 doApply 的"读取 ctx → 处理 → 写 ctx → return null"模式,可以用 Agent 消息链四节点为例:
| 节点 | 读 ctx | 业务动作 | 写 ctx | return |
|---|---|---|---|---|
AgentResolveNode | request.agentId() | 从 agentFactory 取/创建 ReactLoopAgent | ctx.setAgent(agent) | null(让下一节点接手) |
AgentIntentNode | request.message() | 9 个正则打分类 | ctx.setIntent(intent) | null |
AgentDispatchNode | ctx.getAgent() + ctx.getIntent() | 构造用户 Message(含图片解析)、按意图改写文本 | ctx.setUserMessage(msg) | null |
AgentCollectNode | ctx.getAgent() | agent.whenIdle().join() + 遍历 SessionLog 投影 | — | 非 null:AgentMessageResponse(终止) |
提交链的 5 节点也是同样模式,但 ctx 字段更多(8 个 VO)。核心规律:
// 节点 doApply 的统一骨架
protected SubmitTaskResponse doApply(req, ctx) throws Exception {
var upstream = ctx.getXxx(); // 1. 读上游写入的中间状态
var processed = service.doSomething(upstream); // 2. 调领域服务做实事
ctx.setXxx(processed); // 3. 写入自己的产出
return null; // 4. 继续:让下一个节点接手
}6.7.3 null 与非 null:链路的两条退出路径
AbstractStrategyRouter 的 apply 方法是模板的核心——两条退出路径决定了链的语义:
public final O apply(I req, C ctx) throws Exception {
O result = doApply(req, ctx);
if (result != null) return result; // 路径 1:节点主动终止(短路)
return router(req, ctx); // 路径 2:交给下一个节点
}
protected O router(I req, C ctx) throws Exception {
return getNext(req, ctx).apply(req, ctx); // 递归调用
}| 场景 | 节点返回 | 适用 |
|---|---|---|
| 正常流转到下一节点 | null | Agent 链四个节点全是这样;提交链前四个节点也是 |
| 正常结束(叶子节点) | 完整的 O | AgentCollectNode、EnqueueNode——产出最终响应 |
| 错误短路 | 非 null 的错误响应 DTO | 课程里没用,但完全支持——比如"PermissionCheckNode 检查失败就直接返回 error" |
| 404 / 不匹配 | 非 null 占位 | 通用责任链(如网关路由)的常见用法 |
6.7.4 getNext 怎么选下一节点:硬编码 vs 动态
本项目所有节点的 getNext 都返回硬编码的下一个节点引用(通过 @Qualifier 注入)。这是最朴素的写法:
@Service("agentMessageResolveNode")
public class AgentResolveNode extends AbstractAgentMessageNode {
@Autowired @Qualifier("agentMessageIntentNode")
private AgentIntentNode intentNode;
@Override
public StrategyHandler<...> getNext(req, ctx) {
return intentNode; // 永远是 IntentNode
}
}这种写法的代价是:想插入新节点必须改既有节点的 getNext。更高级的写法是让 getNext 根据 ctx 动态决定下一步(路由表/Map/策略),但代价是失去类型安全(返回类型擦除为基类)。本项目选择"少写一层灵活",是"读源码容易 > 扩展极度灵活"的权衡——和整个系统"反过度工程"的味道一致。
如果你发现 getNext 里开始出现"如果 profile 是 web 就走 X、否则走 Y"这种分支——就是升级到动态路由的信号:把每个 next 做成一个 Map,或者抽出 RoutingPolicy 接口。本项目目前规模不需要,但写插件或工作流(ch15)的人应当意识到这条边界。
6.8 小结与下一章预告
本章要点:意图识别用纯正则(零延迟、零成本、可单测),四类意图决定后续处理;策略树 = 责任链 + DynamicContext + 非 null 短路;Agent 消息链四节点(Resolve→Intent→Dispatch→Collect)负责对话,任务提交链五节点(SubmissionRoot→ProfileResolution→PermissionCheck→ToolResolution→Enqueue)负责治理。
下一章:AgentCollectNode 收集的消息怎么推给浏览器?流式对话的 SSE 事件序列长什么样?为什么需要一个专门的 step_break 事件?第7章完整解析 SSE 流式协议。