Stack Overflow 博客第一篇,介绍如何通过确定性层让 Agent 行为可预测、可复现,避免随机性导致的调试困难,是生产级 Agent 架构核心模式。
将 LLM 智能体放入高风险系统的诀窍不在于更聪明的模型——而在于将模型限制在单一节点,从而让系统其余部分都是普通的、可测试的代码。以下是实现这一目标的结构性手法,以及相应的契约和类型定义。
Demo 喜欢那种循环、调用工具、"自己搞定"的自主智能体。生产环境厌恶它们。一旦智能体的行为取决于模型今天游走到了哪条路径,你就无法测试它、无法审计它、也无法让它触碰任何关键的东西。在受监管或后果严重的系统——资金流转、医疗健康、基础设施——"它通常能工作"根本行不通。
这是将 LLM 系统投入生产运营的六级成熟度模型的第一级:确定性层。在你能够做评估、置信度路由或任何更高层级的操作之前,你需要底层的东西先稳住。这一层的全部精髓就是一个想法——将模型限制在单一节点,让周围的一切都是普通的、可测试的代码——并通过一些设计手法来表达它。你保留了模型的智能,但去掉了几乎所有不可预测性。这篇文章给出的是契约,而不仅仅是概念。
最重要的规则:智能体是从上下文到提议决策的纯函数——就业务状态而言是纯的,在给定网关的情况下是确定性的(唯一的非确定性调用,即模型,是被注入的,因此测试时可以伪造它)。智能体没有改变世界的权限。一个单独的、笨重的、经过大量测试的组件——底层基座(substrate)——应用这些提议,且仅在获得所需批准之后才执行。
from typing import Protocol, Literal
from dataclasses import dataclass
@dataclass(frozen=True)
class Proposal:
decision_id: str
capability: str
action: dict # 结构化的提议变更——尚未应用
confidence: float
routing: Literal["auto", "hitl_recommended", "hitl_required", "reject"]
reasoning: list[str]
evidence: list[dict]
class Agent(Protocol):
def propose(self, ctx: "Context") -> Proposal: ... # 纯函数:不产生业务状态的副作用
class Substrate(Protocol):
def apply(self, proposal: Proposal, approval: "Approval") -> "Effect": ... # 唯一的变更器
因为 propose() 是纯函数,它最核心的特性就是一条断言:
def test_propose_is_pure():
agent = ClassifyAgent(gateway=FakeGateway(scripted))
assert agent.propose(ctx) == agent.propose(ctx) # 相同上下文 → 相同提议;未触碰世界
这个边界为你带来了三个属性:
可测试。propose() 是一个纯函数——相同的上下文,相同的提议。测试逻辑时不需要模拟整个世界。
安全。被越狱或有缺陷的智能体产生的是一个糟糕的提议,而不是糟糕的动作。爆炸半径止步于"某个护栏或某个人说了不"。
可组合。智能体永远不会调用其他智能体。工作通过底层基座流动(例如一张 cases 表加一个调度器),因此不存在需要推理的隐藏副作用链。
这一条约束将"一个 AI 做了某件我们无法解释的事"变成了"一个 AI 建议了某件事,以下是批准它的确切依据。"
自由形式的 ReAct 循环适合探索,但不适合提供保证。将每种能力建模为一个固定的节点序列,模型仅在需要判断的地方使用,其他一切都是普通代码。下面的箭头草图为了可读性做了精简;完整的节点列表(含 pre_check 和 memory_write)在下一节。
entry → load context → reason (LLM) → output guardrail → verify →
judge (sampled) → compose confidence → route → prepare proposal → record → exit
自由形式 ReAct 循环 固定图(本文)
-------------- ----------------------- ---------------------------------
控制流 模型决定下一步 事先已知
可测试性 困难(路径可变) 每个节点独立可测试
延迟/成本 无上限 有上限,可预测
审计 从跟踪记录重建 每次都是统一的行
适用场景 开放式探索 必须有保证的决策
你放弃了一些巧妙的灵活性;换回来的是推理系统行为的能力。巧妙的判断部分——对混乱输入的判断——保留在模型最擅长的地方,在一个节点内,被你能读懂的代码包围。
"让智能体具有确定性"在看到具体的形态之前很难付诸行动。让我们逐一节点地走一遍单个请求在图中的完整流程——即上图背后的实现。
状态对象。每个节点读取并写入贯穿整个图的一个类型化状态值。将这一点明确化就解决了一半的问题——这正是让你能够通过构造一个状态并对结果做断言来独立测试每个节点的原因。
from typing import TypedDict, Literal, Optional
class GraphState(TypedDict):
decision_id: str
tenant_id: str
identity: dict # 身份/权威(入口处设置)
inputs: dict # 经验证的请求
context: dict # 加载的记忆/引用片段
model_output: Optional[dict] # LLM 节点的原始结构化输出
guardrail: dict # 已应用的块/脱敏处理
verification: dict # 确定性检查结果
judge: Optional[dict] # 第二次意见结果(如有采样)
confidence: Optional[float] # 组合后的分数
routing: Optional[Literal["auto", "hitl_recommended", "hitl_required", "reject"]]
proposal: Optional[dict] # 最终成型的提议
ledger_entry_id: Optional[str]
节点契约。每个节点都是相同的形状:state -> state。除模型节点外都是确定性的。这种一致性正是你可以对每个节点做单元测试并推理整个系统的原因。
from typing import Protocol
class Node(Protocol):
name: str
def run(self, state: GraphState, deps: "Deps") -> GraphState: ...
图结构。八个节点在所有能力间共享;只有少数是能力特定的。新增一种能力 = 实现约 4 个节点,继承其他 8 个。
1 entry 生成 decision_id,绑定 tenant + identity [共享]
2 pre_check 验证输入、解析引用、廉价的早期退出 [能力特定]
3 context_load 仅获取此决策所需的上下文 [共享]
4 llm_decision 推理步骤——结构化输入,结构化输出 [能力特定]
5 output_guardrail 对模型输出进行 PII/策略清洗 [共享]
6 verification 确定性、能力特定的正确性检查 [能力特定]
7 judge 采样后的第二次模型审查(高风险场景) [共享]
8 confidence_compose 从各信号组合分数 [共享]
9 routing 自动 vs 人工介入 vs 拒绝 [共享]
10 prepare_proposal 塑造最终提议 [能力特定]
11 memory_write 写入智能体自身的审计记忆(非业务状态) [共享]
12 exit 追加不可变的账本条目 [共享]
两个承重节点是 llm_decision 和 verification。第一个是唯一的非确定性节点——结构化输入、schema 约束输出、 mismatch 时重试——周围的一切在其被检查之前都将输出视为不可信的(下一节详述)。第二个是确定性的、能力特定的代码,整个"将模型限制住"的主张就建立在此之上:
def run(self, state, deps):
out = state["model_output"]
checks = {
"in_enum": out["decision"] in ALLOWED_DECISIONS, # 不能返回列表外的动作
"schema_ok": matches_schema(out, DECISION_SCHEMA),
"rules_ok": deps.rules.check(out, state["inputs"]), # 业务不变量
}
state["verification"] = {"checks": checks, "score": sum(checks.values()) / len(checks)}
return state
固定形态的要点在于:你可以读取控制流(路径就是图),模型被限制在一个节点内,每个决策都是统一的——每次形状相同意味着每次审计行相同,也意味着在同一个位置添加检查、指标或护栏。
节点 4 值得单独讨论,因为大量 LLM 的脆弱性来自一个选择:让模型返回自由文本然后再解析。散文是模糊的,格式在多次调用之间漂移,你的下游代码距离因一个意外措辞而崩溃只有一步之遥。"当然!大概是 Approve,虽然也可能是 Escalate"——现在你有了一个 NLU 问题要处理才能提取出 Approve,而明天的改写就会让你的正则表达式失效。你已经将系统耦合到了模型的散文风格,而这是它最不稳定的东西。
解决方案既枯燥又强大:约束模型输出经验证的结构,将其他任何输出视为失败的调用并重试。根据所需保证的严格程度,有三种约束方式:
method how guarantee use when
--------------------------------------------- ------------------------------ ------------------------------------ --------------------------------------
Schema-guided (JSON Schema / response_format) ask for JSON matching a schema strong, provider-enforced most cases
Tool / function call model emits a typed tool call strong; natural for "do X with args" the decision maps to an action
Grammar-constrained decoding constrain tokens to a grammar hard guarantee (can't emit invalid) strict/regulated formats, local models
# the contract as types — downstream consumes a typed object, never prose
from pydantic import BaseModel
from typing import Literal
class Decision(BaseModel):
decision: Literal["approve", "escalate", "reject"] # an enum is itself a guardrail
confidence: float
reasons: list[str]
约束生成减少了格式不良的输出,但没有完全消除它。所以要闭合这个循环:验证每个响应,在不匹配时重试——将验证错误反馈回去。限制重试次数,失败时直接拒绝。
from pydantic import ValidationError
class NonConformingOutput(Exception): ...
def decide(base_prompt, model, max_retries=2) -> Decision:
prompt = base_prompt
for _ in range(max_retries + 1):
raw = model.generate(prompt, schema=Decision.model_json_schema())
try:
return Decision.model_validate_json(raw) # success: typed object
except ValidationError as e:
# rebuild from base_prompt — don't append onto the already-appended prompt (it compounds)
prompt = f"{base_prompt}\n\nYour previous output was invalid: {e}. Return JSON only."
raise NonConformingOutput() # fail closed — never hand downstream a guess
边界验证意味着系统其他部分只会看到格式正确的决策;"模型是否按预期行为"这个棘手的问题被限制在这个函数中处理。你用模糊的 NLU 问题换取了清晰的验证问题——无需解析层,在模型或提示词变更时保持稳定,内置了护栏(固定的枚举无法返回列表以外的值),还获得了黄金测试可以断言的强类型契约。
需要大声强调一点:结构约束的是形式,而不是正确性。一个完全有效的 {"decision":"approve","confidence":0.99} 可能是完全错误的。结构化输出消除了解析失败模式,而不是判断失败模式——这就是为什么它是生产系统的地板,在 evals、验证和置信度之下,而不是它们的替代品。
置信度(模型信号 + 验证 + 采样评判)在置信度节点被组合;路由节点将其转换为四个路径之一,使用单一阈值,prepare_proposal 随后将结果印在提案上:
def route(confidence: float, verified: bool, T: float = 0.85) -> str: # T is per-capability config, not a constant
if not verified: return "reject"
if confidence >= T: return "auto"
if confidence >= T - 0.2: return "hitl_recommended" # close: pre-fill the proposal for a human
return "hitl_required" # low: a human decides from scratch
verified 来自验证节点(verified = all(checks.values())),而不是智能体自己设置的字段,路由器发出所有四种路由状态。注意顺序:路由在提案被塑造之前运行(节点 9 → 节点 10),所以它接收的是普通置信度和 verified 标志——而不是 Proposal。将 T 设得保守——一切都由人工处理——只有当数据证明安全时才按切片降低它。人类注意力,这个昂贵的资源,被精确地花在系统不确定的地方。
每个提议的决策都变成一行不可变的记录——无条件地作为每个决策的最后一个节点写入。不是日志行;而是关于发生了什么以及为什么发生的规范记录。
CREATE TABLE decision_ledger (
decision_id TEXT PRIMARY KEY,
ts TIMESTAMPTZ NOT NULL,
tenant_id TEXT NOT NULL,
capability TEXT NOT NULL,
inputs_hash TEXT NOT NULL, -- hash, not the raw sensitive payload
model_version TEXT NOT NULL,
prompt_version TEXT NOT NULL,
decision JSONB NOT NULL,
confidence REAL NOT NULL,
routing TEXT NOT NULL, -- auto | hitl_* | reject
outcome TEXT, -- recorded later as a NEW superseding row, never an in-place UPDATE
supersedes TEXT REFERENCES decision_ledger(decision_id),
prev_hash TEXT, -- optional hash-chain for tamper-evidence
entry_hash TEXT
);
-- append-only: no UPDATE/DELETE grants; corrections are new rows that set `supersedes`.
对输入进行哈希处理,而不是存储它们——在不承担责任的情况下实现可验证性。使其仅追加:更正取代,永不覆盖。如果你可以 UPDATE 分类账,那它就不是审计跟踪;撤销该授权。在高负载下也不要跳过写入——它是记录的记录,不是可丢弃的遥测数据。
如果你遵循了以上所有——固定图、模型被限制在一个节点——你最终会遇到一个不合适的问题:开放式的任务,模型必须查找某些内容、推理它发现的内容、可能再查找更多、然后决策。这就是 ReAct 风格工具循环的用途。错误不是循环本身;而是无界循环。将自主性作为一种深思熟虑的、有护栏保护的例外来允许——而不是在其他任何地方。
四条护栏保持控制:(1) 硬性迭代上限——用 for,而不是 while(not done),这样模型不会决定何时停止;(2) 按能力的工具允许列表——它只能调用显式限定在此能力范围内的工具;(3) 完整的每次迭代跟踪——每一步都被记录,以便决策可以重放;(4) 与其他所有流程相同的出口——循环的输出仍然通过护栏、验证、置信度和路由,最后仍然是提议而不是行动。
class DisallowedTool(Exception): ... # raised when the loop reaches for an off-list tool
ALLOWED_TOOLS = { # rail 2: per-capability allow-list, not "all tools"
"enrich_request": {"search_kb", "fetch_record", "lookup_reference"},
}
@dataclass
class Step: # rail 3: one trace row per iteration
i: int; action: str; args: dict; result_digest: str
def bounded_react(state, deps, capability, MAX_STEPS=6) -> Proposal:
allow = ALLOWED_TOOLS[capability]
trace: list[Step] = []
for i in range(MAX_STEPS): # rail 1: hard cap
action = deps.model.next_action(state, tools=sorted(allow))
if action.is_final: # check FIRST — a final answer carries no tool
return finalize(action.proposal, trace) # rail 4: still a PROPOSAL
if action.tool not in allow: # defense in depth
raise DisallowedTool(action.tool)
result = deps.tools[action.tool](**action.args)
trace.append(Step(i, action.tool, action.args, digest(result)))
state = state.with_observation(result) # loop-local state type, not the fixed-graph GraphState
return escalate("hit step cap", trace) # bounded: cap hit → returns a Proposal routed "hitl_required"
跟踪记录进入分类账条目,因此基于循环的决策与固定图决策一样可重建。两条护栏各值得测试——它总是终止,并且无法调用允许列表之外的工具:
def test_always_terminates():
deps = fake_deps(model=never_final) # a model that never returns is_final
p = bounded_react(state, deps, "enrich_request", MAX_STEPS=3)
assert p.routing == "hitl_required" # hit the cap → routed to a human, didn't hang
def test_disallowed_tool_refused():
deps = fake_deps(model=calls("danger_tool")) # a tool not on the allow-list
with pytest.raises(DisallowedTool):
bounded_react(state, deps, "enrich_request")
关于是否需要这一层的决策规则:到达决策是否需要依赖发现过程中的步骤数量和顺序?如果否 → 固定图(大多数能力场景)。如果是 → 有界循环、四轨、记录为例外。如果你"为了安全"或"为了灵活性"而使用循环,停下来——那通常是固定图穿了马甲。不需要的灵活性就是凌晨两点还得调试的非确定性。
智能体"就这一次"地写入业务状态。现在它既不纯粹、也无法测试,bug 从一个坏提案变成了一次事故。让底层设施成为唯一的变更者。
在步骤之间以临时元组/字典形式传递的隐式状态——你会失去孤立测试节点的能力。让状态成为一个类型化对象。
在 prompt 中写"返回 JSON"但不强制 schema——你仍然会收到自然语言回复、代码围栏或尾部注释。使用 schema/工具/语法强制,然后验证并重试;受约束 ≠ 有保证。
从模型中"借用"的"置信度"。校准失准;改为从独立信号组合。
可变或被跳过的审计。如果你能 UPDATE 账本,它就不是审计轨迹;如果在负载下丢弃写操作,你就失去了记录本身。
无界循环"为了灵活性"。默认为固定图。当你确实需要循环时,使用 for 上限和每能力的允许列表——永远不要用 while not done 或"所有工具可用"。
第一级是一种纪律的一致应用:将模型限制在一个节点,让智能体成为一个只做提议的纯函数,将该节点约束为经过验证的 schema,从独立信号组合置信度以将边界情况路由给人类,让一个朴素的底层设施在批准后成为唯一变更者,并将每个决策写入只追加账本。当某个能力真正需要自主性时,给它做预算——一个步骤上限、一个工具允许列表、一条追踪,以及与其他所有内容相同的输出检查。
模型仍然在做它唯一擅长的事——对混乱输入的判断——但它周围的系统是确定的、可测试的、可审计的。无聊,是最好的那种无聊:可预测到足以测试,受控到足以信任,有据可查到足以发布。这就是本系列所有后续内容建立的基础。
系列文章:生产环境运行 LLM 系统 — 第 1 级(共 6 级):确定性。