🧠 第三篇:核心机制

第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/cwdAgent 实例、GenerateOptions
AgentIntentNode正则分类意图(CHAT/CODE_QUESTION/TASK_EXECUTION/CLARIFICATION)意图枚举、需注入的指令
AgentDispatchNode构造 Message,调用 agent.send(msg, NEXT_TURN, true) 唤醒驱动器发送结果
AgentCollectNode等待 Agent 空闲,从 SessionLog 投影出完整消息列表(含 Tool 消息按 callId 合并)最终响应消息列表
💡 AgentCollectNode 的一个关键修复

这个节点曾经只投影 UserMessageAssistantMessage漏了 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) 产出 ApprovalDecisionVOEnqueueNode 根据决策分流:免审批直接 executeSession(RUNNING),需审批则停留 PENDING_APPROVAL 等待人工批准。这条链的完整审批流转是第11章的主题。

6.6 为什么策略树适合 Agent 系统

✓ 可插拔

想在意图识别后加一个"敏感词过滤"节点?写个新节点插进链里即可,不用动既有代码。

✓ 可观测

每个节点是天然埋点——在哪个节点短路、耗时多少,都容易统计。

✓ 符合 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。这让节点本身可以注入领域服务(如 agentFactorymodelSettingQueryServicepermissionPolicyService)。
  • StrategyHandler 是 @FunctionalInterface,所以一个节点其实就是一个"业务方法 + 下一个节点引用"的实现——抽象层足够薄。
  • ctx 是每次请求新建的,节点无状态(除了 Spring 注入的依赖),天然线程安全。

6.7.2 DynamicContext 字段流转:实际写的是什么

每个节点 doApply 的"读取 ctx → 处理 → 写 ctx → return null"模式,可以用 Agent 消息链四节点为例:

节点读 ctx业务动作写 ctxreturn
AgentResolveNoderequest.agentId()agentFactory 取/创建 ReactLoopAgentctx.setAgent(agent)null(让下一节点接手)
AgentIntentNoderequest.message()9 个正则打分类ctx.setIntent(intent)null
AgentDispatchNodectx.getAgent() + ctx.getIntent()构造用户 Message(含图片解析)、按意图改写文本ctx.setUserMessage(msg)null
AgentCollectNodectx.getAgent()agent.whenIdle().join() + 遍历 SessionLog 投影nullAgentMessageResponse(终止)

提交链的 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);  // 递归调用
}
场景节点返回适用
正常流转到下一节点nullAgent 链四个节点全是这样;提交链前四个节点也是
正常结束(叶子节点)完整的 OAgentCollectNodeEnqueueNode——产出最终响应
错误短路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 流式协议。