Stack Overflow Blog

Part 1: Make your AI agents boring: the determinism layer

8.5内容质量

TL;DR · AI 摘要

通过确定性层将LLM代理限制在单一节点,使系统可测试且安全。文章提供代码协议和设计模式实现方法。

核心要点

  • Agent仅提出建议不执行,所有变更由Substrate组件处理
  • 纯函数propose()确保相同上下文生成相同提案
  • 确定性层是LLM生产系统六级成熟度模型的第一层

结构提纲

按章节快速跳转。

  1. 高风险系统需要可测试的LLM代理,自主代理存在不可预测性风险。

  2. ·确定性层设计

    通过限制模型到单一节点,使系统其余部分成为普通可测试代码。

  3. ›Agent与Substrate分离

    Agent仅生成提案,Substrate组件负责执行且需审批。

  4. propose()函数纯度确保测试可重复性和安全性。

  5. 展示代码协议如何实现确定性层架构。

思维导图

用一张图看清主题之间的关系。

查看大纲文本(无障碍 / 无 JS 友好)
  • LLM代理确定性层
    • 核心设计原则
      • Agent只提出建议
      • Substrate执行变更
      • 纯函数保证确定性
    • 实现优势
      • 可测试性
      • 安全性
      • 可组合性

金句 / Highlights

值得收藏与分享的关键句。

#LLM代理#确定性层#软件设计#系统安全
打开原文

第1部分:让AI代理变得无趣:确定性层 - Stack Overflow

2026年10月7日

第1部分:让AI代理变得无趣:确定性层

[

将大型语言模型(LLM)代理部署到高风险系统的关键不在于使用更智能的模型,而在于将模型限制在单一节点,使系统其余部分成为可测试的普通代码。以下是实现这一目标的结构化方法,包含相关契约和类型定义。

演示系统偏爱能循环、调用工具并"自行解决问题"的自主代理。但生产环境恰恰需要避免这类设计。一旦代理的行为依赖于模型今天选择的路径,你就无法测试、无法审计,也无法允许其接触任何关键系统。在受监管或高风险系统(如资金流动、医疗健康、基础设施)中,"通常能正常工作"的方案根本不可接受。

这是运行LLM系统的六级成熟度模型的第一级:确定性层。在进行评估、置信度路由或任何更高层次的操作之前,必须确保底层系统保持稳定。这一层级的核心思想只有一个:通过若干设计手段,将模型限制在单一节点,使周围所有组件都成为普通可测试代码。你保留模型的智能性,同时消除几乎所有不可预测性。本文将提供具体的契约定义,而不仅仅是概念说明。

代理提出建议,而非直接行动

最重要的规则:代理是从业务状态到建议决策的纯函数——对业务状态保持纯函数特性,给定网关(唯一非确定性调用,即模型)后行为确定。代理没有改变现实世界的权限。一个独立的、简单的、经过充分测试的组件——基质层(substrate)——负责执行建议,且仅在获得必要批准后执行。

python
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()是纯函数,其核心特性体现在以下断言中:

python
def test_propose_is_pure():
    agent = ClassifyAgent(gateway=FakeGateway(scripted))
    assert agent.propose(ctx) == agent.propose(ctx)   # 相同上下文 → 相同建议;未修改现实世界

这一边界设计带来三个关键特性:

  • 可测试性:propose()是纯函数——相同上下文必然产生相同建议。无需模拟现实世界即可测试逻辑。
  • 安全性:越狱或有缺陷的代理只能生成错误建议,而非直接执行危险操作。影响范围被限制在"护栏机制或人类拒绝"的范围内。
  • 可组合性:代理从不调用其他代理。工作流通过基质层(如案件表+调度器)传递,因此不存在需要推理的隐藏副作用链。

这一单一约束将"AI做了我们无法解释的事情"转变为"AI提出了某个建议,且这里明确显示了批准的具体过程"。

每个能力对应一个固定图谱

自由形式的 ReAct 循环非常适合探索,但无法提供保证。将每个功能建模为固定节点序列,仅在需要判断时使用模型,其余部分均为普通代码。下图的箭头示意图为了可读性进行了简化;完整的节点列表(包含 pre_check 和 memory_write)将在下一节展示。

code
entry → load context → reason (LLM) → output guardrail → verify →
        judge (sampled) → compose confidence → route → prepare proposal → record → exit
code
自由形式 ReAct 循环     固定图结构(此方案)
--------------  -----------------------  ---------------------------------
控制流          模型决定下一步        提前已知
可测试性        困难(路径变化)      每个节点独立测试
延迟/成本       无上限                有界且可预测
审计            从轨迹重建            每次都是统一记录
适用场景        开放式探索            必须保证的决策

你放弃了部分灵活性,但获得了分析系统行为的能力。模型擅长处理复杂输入的判断功能,被精确地保留在一个节点中,周围是你可以阅读的代码。

请求的结构分析

“使代理确定化”在未看到具体形态前很难实施。因此,让我们逐步跟踪一个请求通过图结构的每个节点——这是上述图示的实现原理。

状态对象。每个节点读写一个通过图结构传递的类型化状态值。明确这一点是关键——它使你能够通过构建状态并断言结果来独立测试每个节点。

python
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]

节点契约。每个节点结构相同:状态 -> 状态。唯一的不确定性来自模型节点。这种统一性正是你能够对每个节点进行单元测试并分析整体行为的原因。

python
from typing import Protocol

class Node(Protocol):
    name: str
    def run(self, state: GraphState, deps: "Deps") -> GraphState: ...

图结构。八个节点在所有功能中通用,仅少数节点与具体功能相关。新增功能 = 实现约4个节点,继承其余8个。

code
1  entry              mint decision_id, bind tenant + identity              [shared]
2  pre_check          validate inputs, resolve refs, cheap early-outs       [capability]
3  context_load       fetch only the context this decision needs            [shared]
4  llm_decision       the reasoning step — structured in, structured out    [capability]
5  output_guardrail   PII / policy scrub on the model output                [shared]
6  verification       deterministic, capability-specific correctness checks [capability]
7  judge              sampled second-model review (high-stakes)             [shared]
8  confidence_compose composed score from the signals                       [shared]
9  routing            auto vs hitl vs reject                                [shared]
10 prepare_proposal   shape the final proposal                              [capability]
11 memory_write       write the agent's own audit memory (not business state)[shared]
12 exit               append the immutable ledger entry                     [shared]

两个关键节点是 llm_decision 和 verification。第一个是唯一一个非确定性节点——结构化输入、符合模式的输出、不匹配时重试,其周围所有流程都会在验证前将其输出视为不可信(相关内容将在下一节详细说明)。第二个是确定性的、针对特定能力的代码,整个“限制模型行为”的设计原则正是基于此:

code
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脆弱性源于一个选择:允许模型返回自由文本再进行解析。自由文本存在歧义,格式在调用间漂移,下游代码可能因意外措辞而崩溃。例如“确定!可能是批准,也可能是升级”——现在你需要解决NLU问题来提取“批准”,而明天的改写可能让正则表达式失效。你已将系统耦合到模型的文本风格,而这是最不稳定的特性。

解决方案虽显平淡却效果显著:强制模型输出验证过的结构,将其他任何输出视为失败并重试。约束方式有三种,选择依据是保证的严格程度:

code
方法                                         如何                             保证                             使用场景
---------------------------------------------  ------------------------------  ------------------------------------  --------------------------------------
基于模式(JSON Schema / response_format)  要求生成符合特定模式的JSON   强,由提供方强制执行             大多数场景
工具/函数调用                              模型生成带类型的工具调用     强;适合"使用参数执行X"的场景   决策对应具体操作
语法约束解码                               限制生成的token符合语法    严格保证(无法生成无效内容)    严格/监管格式,本地模型
code
# 以类型定义契约 —— 下游消费的是类型化对象,而非自然语言
from pydantic import BaseModel
from typing import Literal

class Decision(BaseModel):
    decision: Literal["approve", "escalate", "reject"]   # 枚举本身即是安全护栏
    confidence: float
    reasons: list[str]

约束生成能减少格式错误输出,但无法完全消除。因此需要形成闭环:验证每个响应,发现不匹配时重试——将验证错误反馈给模型。限制重试次数并采用失败封闭策略。

code
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)            # 成功:获得类型化对象
        except ValidationError as e:
            # 从基础提示重新构建 —— 不要追加到已存在的提示(会累积)
            prompt = f"{base_prompt}\n\nYour previous output was invalid: {e}. Return JSON only."
    raise NonConformingOutput()      # 失败封闭 —— 永远不向下传递猜测结果

边界验证意味着系统其他部分只能看到结构良好的决策;"模型行为是否合规"这种复杂问题被限制在单一函数中处理。你用明确的验证问题替代了模糊的NLU问题——无需解析层,模型/提示切换时稳定性更强,内置安全护栏(固定枚举无法返回列表外值),且类型契约可被黄金测试用例验证。

需要特别强调的一个注意事项:结构约束的是形式而非正确性。一个完全合法的{"decision":"approve","confidence":0.99}可能完全错误。结构化输出消除的是解析失败模式,而非判断失败模式——这就是为什么它是生产系统的底层基础,需配合评估、验证和置信度机制,而非替代这些机制。

关键环节:置信度 → 路由

置信度(模型信号 + 验证 + 抽样评审)在置信度节点进行组合;路由节点通过单一阈值将其转换为四个路径之一,prepare_proposal随后将该结果标记到提案中:

code
def route(confidence: float, verified: bool, T: float = 0.85) -> str:  # T是按能力配置的参数,非固定值
    if not verified:           return "reject"
    if confidence >= T:        return "auto"
    if confidence >= T - 0.2:  return "hitl_recommended"   # 接近阈值:预填充提案供人工确认
    return "hitl_required"                                 # 低置信度:人工从零开始决策
code

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, -- 哈希值,而非原始敏感负载 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, -- 后续以新行记录为超继行,永不原地更新 supersedes TEXT REFERENCES decision_ledger(decision_id), prev_hash TEXT, -- 可选的哈希链用于防篡改 entry_hash TEXT ); -- 只追加:禁止 UPDATE/DELETE 权限;更正以新行形式记录并设置 supersedes。

code

对输入进行哈希处理,而非存储它们——实现可验证性的同时避免责任风险。确保账本只追加:更正通过新行超继旧记录,永不覆盖。如果允许更新账本,它就不是审计轨迹;撤销该权限。并且永远不要在高负载时跳过写入——这是权威记录,而非可丢弃的遥测数据。

### 有界 ReAct:循环唯一合法存在的地方

如果你已遵循上述所有规则——固定图结构、模型仅限于单个节点——最终会遇到一个无法适配的问题:需要模型查找信息、分析结果、可能进一步查找,最后做出决策的开放性场景。这就是 ReAct 风格工具循环的用途。错误不在于循环本身,而在于无界循环。将自主性作为经过深思熟虑、受严格限制的例外处理——且仅限于此场景。

四个限制条件确保其可控性:(1) 严格迭代次数上限——禁止使用 while(not done) 这类无限制循环,模型无法自行决定何时停止;(2) 按能力划分的工具白名单——仅能调用明确列出的、与当前能力绑定的工具;(3) 每次迭代的完整追踪记录——每一步都被记录,确保决策可回放;(4) 与其他流程相同的退出机制——循环的输出仍需经过防护网、验证、置信度评估和路由处理,最终仍以提案形式输出,永不直接执行。

class DisallowedTool(Exception): ... # 当循环尝试调用白名单外工具时抛出

ALLOWED_TOOLS = { # 限制 2:按能力划分的白名单,而非“所有工具” "enrich_request": {"search_kb", "fetch_record", "lookup_reference"}, }

@dataclass class Step: # 限制 3:每次迭代生成一条追踪记录 i: int; action: str; args: dict; result_digest: str

code

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"

code

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")

code

是否需要使用此机制的判断规则:做出决策是否需要依赖那些步骤数量和顺序取决于沿途发现内容的步骤?不需要 → 固定图(大多数能力)。需要 → 有界循环,四条规则,作为例外情况记录。如果你为了“安全”或“灵活性”而使用循环,请停止 —— 这通常是固定图场景的伪装。不需要的灵活性就是凌晨2点要调试的非确定性。

### 反模式

- 代理仅在此处编写业务状态。现在它不再是纯函数,无法测试,错误会变成事件而非糟糕的提案。保持基质层为唯一修改者。

- 隐式状态以临时元组/字典在步骤间传递 —— 你将失去单独测试节点的能力。将状态设为类型化对象。

- 提示中要求“返回JSON”但没有模式约束 —— 你仍会得到散文、围栏或尾注。使用模式/工具/语法约束,然后验证并重试;约束 ≠ 保证。

- 从模型中提取“置信度”。校准错误;改用独立信号组合而成。

- 可变或跳过的审计。如果你能更新账本,那不是审计轨迹;如果在负载下丢弃写入,你将没有记录。

- 为“灵活性”使用无界循环。默认使用固定图。使用循环时,采用for循环上限和按能力的允许列表 —— 永远不要使用“while not done”或“所有工具可用”。

### 总结

Level 1 是一项被严格应用的准则:将模型限制在单一节点,使代理成为仅提出建议的纯函数,将该节点约束到经过验证的模式,通过独立信号合成置信度以将临界情况路由给人类,在获得批准后让一个简单的底层系统作为唯一修改者,并将每个决策记录到仅追加的账本中。当某种能力确实需要自主性时,需进行预算控制——设置步骤上限、工具白名单、进行追踪,并与其他所有内容执行相同的输出检查。

模型仍然专注于其擅长的领域——对复杂输入进行判断——但围绕它的系统是确定性的、可测试的、可审计的。以最理想的方式:足够可预测以进行测试,足够受控以建立信任,足够可辩护以投入生产。本系列后续所有内容都是建立在这个基础之上的。

系列文章:在生产环境中运行 LLM 系统 —— 第 1 级:确定性

]

这些文章采用知识共享 署名-相同方式共享 4.0 国际许可协议授权。

creativecommons.org/licenses/by-sa/4.0/deed.en