实践:如何为智能体推理引入外部决策步骤

引言

在智能体系统中,我们通常默认: 只要模型足够强、上下文足够多,推理就可以在系统内部完成。

但在实际工程中,这个假设并不总是成立。

当推理需要依赖尚未被明确表示的信息、需要更精确的结构化输入,或需要与真实世界保持一致时,继续内部推理反而会降低系统质量。

本文讨论一种实践路径: 在智能体的推理流程中,引入一个显式的外部决策步骤(Externalized Cognitive Step),并将其作为执行模型的一部分,而不是交互层的补丁。

以下内容基于 Wenko 的真实实现。Wenko 是一个基于 LangGraph 构建的桌面智能体系统,其执行图(execution graph)中包含了一个可暂停、可恢复的外部决策节点。本文将以该实现为例,说明这一设计如何在工程上落地。

Wenko 项目仓库地址:github.com/daijinru/we...

一、为什么内部推理并不总是最优选择

智能体推理失败,通常不是因为模型"不聪明",而是因为以下几类信息缺失:

  • 模糊但关键的参数(时间、范围、偏好)
  • 隐含但未表达的约束条件
  • 需要结构化表示而非自然语言理解的内容
  • 系统需要与外部世界保持一致的状态

在这些场景下,继续让模型"猜"会带来两个问题:

  1. 推理结果高度不稳定
  2. 错误往往在后续步骤中被放大

工程上更理性的做法不是强化猜测能力,而是中断推理,转而获取更可靠的信息来源。

二、什么是"外部决策步骤"

所谓外部决策步骤,并不是指让系统"放弃智能",而是:

在推理链条中,将一个无法在当前上下文中完成的决策, 显式外包为一个独立、可暂停、可恢复的步骤。

它具有几个关键特征:

  • 由系统主动触发,而非用户随意插入
  • 以结构化请求的形式表达,而非自然语言追问
  • 会中断当前推理流程
  • 只有在获得外部输入后才继续

从系统角度看,这是一次受控的推理暂停。

在 Wenko 中的对应结构

这个"受控的推理暂停"在 Wenko 中被实现为 GraphState 上的一次状态转移。GraphState 是整个执行图的单一状态源(Single Source of Truth),其中与外部决策相关的核心字段如下:

python 复制代码
# workflow/core/state.py

class GraphState(BaseModel):
    status: Literal["idle", "processing", "suspended", "error"] = "idle"
    ecs_request: Optional[ECSRequest] = None
    ecs_full_request: Optional[Dict[str, Any]] = None
    last_human_input: Optional[Dict[str, Any]] = None

其中 ECSRequest 定义了一个决策请求的最小数据契约:

python 复制代码
class ECSRequest(BaseModel):
    type: str       # 'form', 'confirmation', 'visual_display'
    message: str
    options: List[Dict[str, Any]]
    context_data: Dict[str, Any]

几个要点:

  • status 的四种状态(idleprocessingsuspendedidle)构成了一个显式的生命周期。suspended 不是错误,不是等待------它是一种被设计出来的执行状态。
  • ecs_request 是推理节点写入的,不是外部注入的。换言之,是推理过程自身判定"我需要外部信息",而不是某个外部控制器强制中断了它。
  • last_human_input 只在恢复阶段才被填充,用于在下一轮推理中作为高置信度上下文注入。

三、为何要将它设计为"步骤",而不是"对话"

一个常见替代方案是: 让智能体通过多轮对话逐步澄清信息。

这种方式在简单场景下有效,但在工程系统中存在明显问题:

  • 信息分散在多轮上下文中,难以结构化处理
  • 模型需要自行判断哪些信息是"最终版本"
  • 无法明确区分"输入阶段"和"推理阶段"

将外部决策设计为一个独立步骤,可以带来清晰的边界:

  • 在进入该步骤前,系统停止推进推理
  • 在该步骤完成前,不发生任何后续决策
  • 外部输入以完整、一次性的形式返回系统

这使得推理流程在时间和语义上都更加可控。

对话澄清 vs. 结构化决策步骤

为什么不用对话?根本原因在于信息的结构保真度

在 Wenko 的实现中,外部决策请求携带的不是一个自然语言问题,而是一个完整的结构化 schema。后端的 ECSField 定义了每个待采集字段的精确约束:

python 复制代码
# workflow/ecs_schema.py

class ECSField(BaseModel):
    name: str
    type: ECSFieldType    # TEXT, SELECT, NUMBER, SLIDER, DATE, BOOLEAN...
    label: str
    required: bool = False
    placeholder: Optional[str] = None
    default: Optional[Any] = None
    options: Optional[List[ECSOption]] = None
    min: Optional[float] = None
    max: Optional[float] = None

当系统需要用户的"音乐偏好"时,它不会问"你喜欢什么音乐"------它会生成一个包含 SELECT(类型选择)、SLIDER(频率)、TEXT(补充说明)的字段集合。返回的数据是 Dict[str, Any],不是一句自然语言,不需要模型再做一次解析。

这是一个关键的设计判断:在决策边界上,用结构替代语言。

四、工程实现:一个可暂停的推理模型

在实现层面,引入外部决策步骤意味着对执行模型做出明确约束。

1. 推理结果不直接驱动执行

当智能体判断需要外部决策时,它不会继续生成"最终答案",而是生成一个决策请求对象,其中包含:

  • 当前推理意图
  • 所需信息的结构化定义
  • 可选项或默认值
  • 对输入结果的预期用途

该对象本身是推理的一部分,而不是 UI 产物。

触发机制

在 Wenko 中,ReasoningNode 是执行图中唯一的 LLM 调用节点。它在完成 LLM 调用并解析响应后,检查输出中是否包含外部决策请求:

python 复制代码
# workflow/core/nodes/reasoning.py --- ReasoningNode.compute()

if is_ecs_enabled():
    ecs_request = extract_ecs_from_llm_response(full_response)
    if ecs_request:
        updates["ecs_request"] = ECSRequest(
            type=ecs_request.type,
            message=ecs_request.title,
            options=[],
            context_data={"ecs_id": ecs_request.id},
        )
        updates["status"] = "suspended"
        updates["ecs_full_request"] = ecs_request.model_dump(mode='json')
        return updates

注意这里的控制流:return updates 意味着 ReasoningNode 不再产生 response,不再更新对话历史,不再执行后续逻辑。它的唯一输出是一个决策请求和一个状态标记。

为什么不让模型同时输出回复和决策请求?因为这会模糊决策边界------如果模型已经给出了回复,那外部决策的结果还能改变什么?外部决策步骤的前提是:当前推理必须等待外部信息才能产出有效结果。

2. 推理流程进入显式暂停状态

一旦外部决策步骤被触发:

  • 当前执行图终止
  • 系统状态被标记为"等待外部输入"
  • 不再发生任何隐式推理或自动推进

这种暂停是结构性的,而不是通过 sleep、await 或轮询实现。

执行图中的路由与终止

暂停的实现依赖 LangGraph 的条件边(conditional edge)机制。ReasoningNode 的输出经过路由函数分发:

python 复制代码
# workflow/core/graph.py --- GraphOrchestrator._build_text_graph()

def route_reasoning(state: GraphState):
    if state.pending_tool_calls:
        return "tools"
    if state.ecs_request:
        return "ecs"
    return END

ecs_request 不为空时,执行流进入 ECSNode。而 ECSNode 本身几乎不做任何事------它只是确认暂停状态:

python 复制代码
# workflow/core/nodes/ecs.py

class ECSNode:
    async def execute(self, state: GraphState) -> Dict[str, Any]:
        if not state.ecs_request:
            return {"status": "processing"}
        return {"status": "suspended"}

紧接着,一条无条件边将 ECSNode 连接到 END

python 复制代码
workflow.add_edge("ecs", END)   # 挂起,等待恢复

整个执行图在此终止。不是"暂停在某个节点等待回调",而是图的执行彻底结束。状态被持久化,外部决策请求通过 SSE 事件推送到前端。

完整的执行图拓扑如下:

scss 复制代码
IntentNode → EmotionNode → MemoryNode → ReasoningNode → ?
                                                         │
                                         ┌───────────────┼───────────────┐
                                         ▼               ▼               ▼
                                      ToolNode        ECSNode           END
                                         │               │          (正常完成)
                                         │               ▼
                                         │              END
                                         │          (挂起等待)
                                         ▼
                                    ReasoningNode
                                      (循环)

为什么 ECSNode 要连接到 END 而不是"挂起等待"?因为 LangGraph 的执行模型是流式的------每次 astream() 调用对应一次完整的图遍历。没有"在图中间暂停"的语义。暂停必须表达为图的终止,恢复必须表达为新图的启动。这不是实现上的妥协,而是对"推理暂停"的一种更干净的建模:暂停就是当前推理链的结束,恢复就是一次全新推理的开始。

3. 外部输入作为新上下文重新注入

当外部输入完成后,系统并不"接着往下跑",而是:

  • 构建一个新的推理起点
  • 将外部决策结果作为高置信度上下文注入
  • 重新执行完整的推理流程

恢复路径

GraphRunner.resume() 方法处理恢复。它不会加载之前的执行状态,而是构建一个全新的 GraphState

python 复制代码
# workflow/graph_runner.py --- GraphRunner.resume()

ecs_context = build_continuation_context(continuation_data)

initial_state = GraphState(
    conversation_id=session_id,
    semantic_input=SemanticInput(
        text=f"请根据我刚才提交的表单信息给出回复。\n\n{ecs_context}",
    ),
    last_human_input={
        "action": continuation_data.action,
        "form_data": continuation_data.form_data,
        "field_labels": continuation_data.field_labels,
    },
)

orchestrator = GraphOrchestrator(...)
workflow = orchestrator.build()
app = workflow.compile()

async for output in app.astream(initial_state, config={"recursion_limit": 50}):
    ...

几个关键设计选择:

为什么不断点续跑? 系统中存在 checkpoint 机制(_save_checkpoint / _load_checkpoint),但恢复流程故意没有使用它。原因是:外部决策的返回结果可能改变推理的所有前提------意图判断、情绪推断、记忆检索都可能因为新信息而产生不同结论。如果从断点继续,这些节点的输出是基于旧信息的,系统会在新信息和旧推理之间产生不一致。

上下文注入如何工作? build_continuation_context() 将结构化的表单数据转换为 LLM 可读的上下文字符串,同时根据表单复杂度(HIGH / MEDIUM / LOW)生成不同粒度的响应指导。外部输入不是直接丢给模型的原始数据,而是经过格式化的、带有元信息的上下文块。

last_human_input 的作用是什么? 它以原始结构化格式保留外部输入,与 semantic_input.text 中的自然语言表示形成互补。下游节点可以选择直接读取结构化数据,而不必从自然语言中重新提取。

这种方式的好处在于:

  • 推理路径始终是完整的
  • 系统可以自然地重新评估意图、情绪、记忆等因素
  • 外部输入不会被当作临时补丁,而是正式上下文的一部分

链式外部决策

恢复后的推理可能再次触发外部决策。GraphRunner.resume() 在流式输出中检测 ecs_request

python 复制代码
# 恢复执行中检测链式 ECS
if "ecs_request" in update and update["ecs_request"]:
    ecs_req = update.get("ecs_full_request")
    if ecs_req:
        store_ecs_request(ecs_req, session_id)
        ecs_payload = self._format_ecs_payload(ecs_req, session_id)
        yield self._format_sse("ecs", {"type": "ecs", "payload": ecs_payload})

这意味着外部决策不是一次性机制,而是可组合的。一次推理恢复后,如果新信息引发了新的不确定性,系统会再次挂起。这种递归能力是将外部决策建模为执行图中的一等节点(而非特殊分支)带来的自然结果。

五、为什么这种设计更可靠

1. 不确定性被显式建模

系统不再假装"我已经知道答案",而是承认:

当前状态下,内部信息不足以继续。

这种承认本身是一种工程成熟度。

在 Wenko 中,这表现为 status: "suspended" 是一个与 "processing""error" 平级的状态------不是异常,不是降级,而是执行模型的正常分支。

2. 外部输入的价值被最大化

结构化的外部决策输入:

  • 减少歧义
  • 降低模型解释成本
  • 提高后续推理的稳定性

在实现层面,这依赖两个机制:

结构化采集ECSField 的类型系统(TEXT, SELECT, NUMBER, SLIDER, DATE, BOOLEAN 等 11 种字段类型)确保返回数据的类型安全。模型不需要从"我大概每周听三次"中提取频率值------它直接收到 {"frequency": 3}

持久化与复用ecs_handler 将外部决策结果写入工作记忆(working memory)的 context_variables,以 ecs_{request_title} 为键。这意味着同一决策的结果可以在后续推理中被复用,而不需要重复询问。

3. 推理与交互职责清晰分离

  • 推理阶段:系统负责判断"缺什么"
  • 外部决策阶段:外部世界负责"给什么"
  • 恢复阶段:系统负责"如何使用这些信息"

每个阶段只承担一种责任。

在执行图中,这三个阶段对应三段独立的执行路径:

  1. 判断阶段IntentNode → EmotionNode → MemoryNode → ReasoningNode,在 ReasoningNode 中决定是否需要外部信息
  2. 暂停阶段ECSNode → END,图终止,状态持久化
  3. 恢复阶段GraphRunner.resume() 构建新图,外部输入作为初始上下文,重新经过完整的 IntentNode → EmotionNode → MemoryNode → ReasoningNode 链路

三段路径之间没有共享的中间状态,只有通过 GraphState 传递的显式数据契约。

六、适合这种设计的典型场景

这种外部决策步骤尤其适合以下场景:

  • 需要结构化参数的任务(计划、配置、筛选)
  • 与真实世界状态强相关的操作
  • 用户偏好尚未明确、但对结果影响显著的决策
  • 任何"继续猜测会显著降低质量"的情况

它并不适用于所有对话,而是用于高价值、低容错的推理节点。

结语

为智能体推理引入外部决策步骤,并不是削弱智能体能力,而是承认一个事实:

并非所有认知都应当被压缩进一次语言生成。

通过将关键决策显式外包、暂停执行并重新注入上下文,系统获得了更稳定的推理基础,也获得了与外部世界协作的能力。

在 Wenko 的实现中,这套机制最终体现为几个简洁的工程约束:

  • 一个四状态的生命周期(idleprocessingsuspendedidle
  • 一条从 ECSNodeEND 的终止边
  • 一个不从断点恢复、而是从头构建新推理的 resume() 方法
  • 一份结构化的数据契约(ECSRequestECSResponseDataECSContinuationData
相关推荐
无限大61 小时前
AI实战02:一个万能提示词模板,搞定90%的文案/设计/分析需求
前端·后端
朝阳5812 小时前
控制 Nuxt 页面的渲染模式:客户端 vs 服务端渲染
前端·javascript
发现一只大呆瓜2 小时前
Vue-Vue2与Vue3核心差异与进化
前端·vue.js·面试
sunny_2 小时前
熬夜通宵读完 VitePlus 全部源码,我后悔没早点看
前端·前端框架·前端工程化
发现一只大呆瓜2 小时前
Vue2:数组/对象操作避坑大全
前端·vue.js·面试
发现一只大呆瓜2 小时前
Vue3:ref 与 reactive 超全对比
前端·vue.js·面试
lzksword3 小时前
C++ Builder XE OpenDialog1打开多文件并显示xls与xlsx二种格式文件
java·前端·c++
陈随易3 小时前
站在普通开发者的角度,我觉得 RollCode 更像是“把 H5 交付这件事重新捋顺了”
前端·后端·程序员
陈随易3 小时前
RollCode:不只是在做页面,而是在缩短“从需求到上线”的整条链路
前端·后端