引言
在智能体系统中,我们通常默认: 只要模型足够强、上下文足够多,推理就可以在系统内部完成。
但在实际工程中,这个假设并不总是成立。
当推理需要依赖尚未被明确表示的信息、需要更精确的结构化输入,或需要与真实世界保持一致时,继续内部推理反而会降低系统质量。
本文讨论一种实践路径: 在智能体的推理流程中,引入一个显式的外部决策步骤(Externalized Cognitive Step),并将其作为执行模型的一部分,而不是交互层的补丁。
以下内容基于 Wenko 的真实实现。Wenko 是一个基于 LangGraph 构建的桌面智能体系统,其执行图(execution graph)中包含了一个可暂停、可恢复的外部决策节点。本文将以该实现为例,说明这一设计如何在工程上落地。
Wenko 项目仓库地址:github.com/daijinru/we…
一、为什么内部推理并不总是最优选择
智能体推理失败,通常不是因为模型"不聪明",而是因为以下几类信息缺失:
- 模糊但关键的参数(时间、范围、偏好)
- 隐含但未表达的约束条件
- 需要结构化表示而非自然语言理解的内容
- 系统需要与外部世界保持一致的状态
在这些场景下,继续让模型"猜"会带来两个问题:
- 推理结果高度不稳定
- 错误往往在后续步骤中被放大
工程上更理性的做法不是强化猜测能力,而是中断推理,转而获取更可靠的信息来源。
二、什么是"外部决策步骤"
所谓外部决策步骤,并不是指让系统"放弃智能",而是:
在推理链条中,将一个无法在当前上下文中完成的决策, 显式外包为一个独立、可暂停、可恢复的步骤。
它具有几个关键特征:
- 由系统主动触发,而非用户随意插入
- 以结构化请求的形式表达,而非自然语言追问
- 会中断当前推理流程
- 只有在获得外部输入后才继续
从系统角度看,这是一次受控的推理暂停。
在 Wenko 中的对应结构
这个"受控的推理暂停"在 Wenko 中被实现为 GraphState 上的一次状态转移。GraphState 是整个执行图的单一状态源(Single Source of Truth),其中与外部决策相关的核心字段如下:
# 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 定义了一个决策请求的最小数据契约:
class ECSRequest(BaseModel):
type: str # 'form', 'confirmation', 'visual_display'
message: str
options: List[Dict[str, Any]]
context_data: Dict[str, Any]
几个要点:
status的四种状态(idle→processing→suspended→idle)构成了一个显式的生命周期。suspended不是错误,不是等待——它是一种被设计出来的执行状态。ecs_request是推理节点写入的,不是外部注入的。换言之,是推理过程自身判定"我需要外部信息",而不是某个外部控制器强制中断了它。last_human_input只在恢复阶段才被填充,用于在下一轮推理中作为高置信度上下文注入。
三、为何要将它设计为"步骤",而不是"对话"
一个常见替代方案是: 让智能体通过多轮对话逐步澄清信息。
这种方式在简单场景下有效,但在工程系统中存在明显问题:
- 信息分散在多轮上下文中,难以结构化处理
- 模型需要自行判断哪些信息是"最终版本"
- 无法明确区分"输入阶段"和"推理阶段"
将外部决策设计为一个独立步骤,可以带来清晰的边界:
- 在进入该步骤前,系统停止推进推理
- 在该步骤完成前,不发生任何后续决策
- 外部输入以完整、一次性的形式返回系统
这使得推理流程在时间和语义上都更加可控。
对话澄清 vs. 结构化决策步骤
为什么不用对话?根本原因在于信息的结构保真度。
在 Wenko 的实现中,外部决策请求携带的不是一个自然语言问题,而是一个完整的结构化 schema。后端的 ECSField 定义了每个待采集字段的精确约束:
# 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 调用并解析响应后,检查输出中是否包含外部决策请求:
# 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 的输出经过路由函数分发:
# 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 本身几乎不做任何事——它只是确认暂停状态:
# 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:
workflow.add_edge("ecs", END) # 挂起,等待恢复
整个执行图在此终止。不是"暂停在某个节点等待回调",而是图的执行彻底结束。状态被持久化,外部决策请求通过 SSE 事件推送到前端。
完整的执行图拓扑如下:
IntentNode → EmotionNode → MemoryNode → ReasoningNode → ?
│
┌───────────────┼───────────────┐
▼ ▼ ▼
ToolNode ECSNode END
│ │ (正常完成)
│ ▼
│ END
│ (挂起等待)
▼
ReasoningNode
(循环)
为什么 ECSNode 要连接到 END 而不是"挂起等待"?因为 LangGraph 的执行模型是流式的——每次 astream() 调用对应一次完整的图遍历。没有"在图中间暂停"的语义。暂停必须表达为图的终止,恢复必须表达为新图的启动。这不是实现上的妥协,而是对"推理暂停"的一种更干净的建模:暂停就是当前推理链的结束,恢复就是一次全新推理的开始。
3. 外部输入作为新上下文重新注入
当外部输入完成后,系统并不"接着往下跑",而是:
- 构建一个新的推理起点
- 将外部决策结果作为高置信度上下文注入
- 重新执行完整的推理流程
恢复路径
GraphRunner.resume() 方法处理恢复。它不会加载之前的执行状态,而是构建一个全新的 GraphState:
# 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:
# 恢复执行中检测链式 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. 推理与交互职责清晰分离
- 推理阶段:系统负责判断"缺什么"
- 外部决策阶段:外部世界负责"给什么"
- 恢复阶段:系统负责"如何使用这些信息"
每个阶段只承担一种责任。
在执行图中,这三个阶段对应三段独立的执行路径:
- 判断阶段:
IntentNode → EmotionNode → MemoryNode → ReasoningNode,在ReasoningNode中决定是否需要外部信息 - 暂停阶段:
ECSNode → END,图终止,状态持久化 - 恢复阶段:
GraphRunner.resume()构建新图,外部输入作为初始上下文,重新经过完整的IntentNode → EmotionNode → MemoryNode → ReasoningNode链路
三段路径之间没有共享的中间状态,只有通过 GraphState 传递的显式数据契约。
六、适合这种设计的典型场景
这种外部决策步骤尤其适合以下场景:
- 需要结构化参数的任务(计划、配置、筛选)
- 与真实世界状态强相关的操作
- 用户偏好尚未明确、但对结果影响显著的决策
- 任何"继续猜测会显著降低质量"的情况
它并不适用于所有对话,而是用于高价值、低容错的推理节点。
结语
为智能体推理引入外部决策步骤,并不是削弱智能体能力,而是承认一个事实:
并非所有认知都应当被压缩进一次语言生成。
通过将关键决策显式外包、暂停执行并重新注入上下文,系统获得了更稳定的推理基础,也获得了与外部世界协作的能力。
在 Wenko 的实现中,这套机制最终体现为几个简洁的工程约束:
- 一个四状态的生命周期(
idle→processing→suspended→idle) - 一条从
ECSNode到END的终止边 - 一个不从断点恢复、而是从头构建新推理的
resume()方法 - 一份结构化的数据契约(
ECSRequest→ECSResponseData→ECSContinuationData)