具备回放安全性的智能体:通过时间隔离机制避免大语言模型调用干扰工作流程。
为何在运行过程中崩溃的智能体不得再次调用其大语言模型:将操作视为非确定性边界、回放机制、心跳检测、重试以及无状态图结构。
想象一下,当智能体的工作进程死亡时,它正处于10个步骤中的第7步:可能是内存不足导致的进程终止、维护事件,或是内核崩溃。如果调用大型语言模型的操作位于工作流内部,那么恢复过程就相当于悄悄重写历史记录。工作流会再次启动模型,得到不同的回答,智能体最终会出现在它死亡之前的位置之外。同样的令牌会被支付两次费用,而恢复后的运行与原始运行完全不同。
这并非思想实验。某天凌晨2点14分,一名客服人员关闭了一个本应享有30天宽限期的客户账户。工具调用成功了。当工程师重新处理该客户的消息时,智能体表现正常。事后分析中本应指出根本原因的部分却空缺着,只写着“无法复现”。这种无法复现的故障并未消失,只是不再能够被研究而已。
防止这种情况出现的规则很简单:负责协调智能体的代码必须是确定性的,而大型语言模型并非如此,因此它必须位于一个并非必需的边界之后。示例中使用了Temporal作为引擎,但这种模式与具体框架无关。同样的原则同样适用于AWS Step Functions、Prefect和Dagster,也适用于读取数据库消息队列的轮询程序以及自制的状态表,后续章节会将这一理念应用到这些技术栈中。
词汇表出现之前的漏洞
假设有一个包含五个步骤的来源获取智能体:研究、评分、决策、批准、确认。其中“决策”步骤会调用大型语言模型,而最自然的做法就是直接在工作流类中实现这一调用:
# ❌ What I almost wrote — LLM call INSIDE the workflow.
@workflow.defn
class SourcingWorkflow:
@workflow.run
async def run(self, brief: SourcingBriefInput) -> SourcingResult:
suppliers = await workflow.execute_activity(research_activity, brief)
scored = await workflow.execute_activity(score_activity, suppliers)
# The LLM call — right here, in the workflow. THIS IS THE BUG.
# If worker dies here, replay will re-call the LLM and mutate state/history.
response = await llm_client.complete(
messages=build_decide_prompt(scored),
temperature=0.0,
)
selected = parse_llm_decision(response.content, scored)
approval = await workflow.wait_condition(...)
# ...create PO, initiate payment
运行效果不错。那些从未见过Temporal的高级工程师也会认为它表现良好。问题出现在当LLM调用返回后、人工审批仍在处理期间,若工作人员的操作出现故障时。
解决方案是将该调用纳入一个活动流程中,这是一个由工作流调度执行的函数,其执行结果会被记录下来:
# ✅ The fix — LLM call moved into an activity.
@activity.defn
async def decide_activity(scored: list[dict], brief: SourcingBriefInput) -> dict:
"""The LLM call lives here — in the activity, not the workflow."""
from app.agentmesh.llm import get_llm_client
client = get_llm_client()
response = await client.complete(
messages=build_decide_prompt(scored),
temperature=0.0,
)
selected, rationale = parse_llm_decision(response.content, scored)
return {"selected_supplier": selected, "decision_reason": rationale}
@workflow.defn
class SourcingWorkflow:
@workflow.run
async def run(self, brief: SourcingBriefInput) -> SourcingResult:
suppliers = await workflow.execute_activity(research_activity, brief)
scored = await workflow.execute_activity(score_activity, suppliers)
# The LLM call is NOWHERE in the workflow.
# The activity result is recorded. On replay, it's injected.
decision = await workflow.execute_activity(
decide_activity,
args=(scored, brief),
)
有一段代码被移动了。重新运行时,该任务不会再执行,因为工作流会获取已记录的结果,模型也就不会再被调用第二次。其背后的原理才是真正的关键所在。
原则:重放必须重现历史
在工作流运行过程中,Temporal会将每个步骤记录到事件历史中,这是一份只会不断增长的日志,其中包含诸如工作流启动、任务调度、任务完成、信号接收以及计时器触发等记录。这些事件是不可更改的,而历史记录则是事实的依据。
发生故障后,Temporal会重新执行工作流。一个新的工作进程会从工作流的第一行开始运行该函数。每当调用如workflow.execute_activity(...)或workflow.wait_condition(...)这样的函数时,Temporal都会查询历史记录。如果存在匹配的事件,它会直接使用已记录的结果,而不会再次执行任何操作。如果函数发出的调用与历史记录中的不同,Temporal就会抛出非确定性错误。
这就是事件源架构。历史记录就是日志,工作流函数则是基于该日志的折叠操作,以相同顺序通过相同函数处理相同的事件总会得到相同的状态。Temporal的文档明确指出:为支持回放,工作流代码必须是确定性的,而API调用、大语言模型调用及数据库查询等非确定性操作则应属于活动范畴。活动仅执行一次,其结果会被存入历史记录,之后回放操作就会从那里读取数据。
任何使用过Postgres的人都能看到类似的架构。预写日志就是记录,表文件相当于物化视图,而恢复过程则是对日志的回放。一份关于事件源架构的指南也阐述了相同的原理:相同的事件、相同的顺序、相同的最终状态,在折叠操作过程中不允许出现时钟、随机ID或基于时间段的读取操作。
违反规则时的表现
当大型语言模型被集成到工作流中时,会有两件事同时出现问题。
成本翻倍。第一次调用只需3美分,再次调用时仍需3美分。有团队统计发现,对于单次对话,不受控制的重试会在30秒内消耗约2美元的代币成本,而通过断路机制后这一成本降至1美分,相差200倍。
执行结果出现差异。最初的执行选择了供应商B,而重试时却选择了供应商A。由于历史记录不再一致,Temporal会触发非确定性错误,从而导致工作流无法继续运行。
这些并非罕见现象。在30天的时间内,大约有0.8%的Anthropic和OpenAI调用会失败,因此每个任务执行12次模型调用的智能体,其单任务失败率接近9.2%。如果大语言模型位于工作流中,每次重试都会重新触发该模型调用;而如果它位于某个活动中,重试则会使用之前已记录的结果。Temporal的官方说明已明确区分了这两者:工作流属于调度层,必须具有确定性,这样才能确保智能体在流程崩溃时仍能正常运行;而活动则是实际任务执行的场所,包括大语言模型调用、工具调用以及API请求,这些操作完全可以按照需要保持不可预测性。
为何温度值为0并不能起到保护作用
总有人会提出温度值被设置为0这一理由,但这一做法并无帮助,原因有以下几点:
- 零温度意味着贪婪解码,而非确定性。有分析显示,一千个完全相同的请求会生成80种不同的结果,这是由于GPU上批量处理时浮点数存在的差异所致。
- 相同的模型名称并不代表相同的模型。服务提供商会悄悄更新模型,因此周二使用的模型可能与周三的不一致。
- 在回放过程中服务提供商可能会宕机。这样一来,整个工作流程就会陷入等待状态。
- 即便答案在字节级上完全相同,也需要付费。仍然会收取第二次推理的费用。
一位使用Tisha名义撰文的开发者精准地指出了问题所在:团队们花费数周时间试图让模型实现位级确定性,但最终失败,于是得出系统不可预测的结论,而实际上真正的确定性存在于更高层的调度机制中。他们找错了解决问题的层面。
作为膜的 Activity
这种变化是结构上的。在 Activity 内部,任何事情都是允许的;而在其外部,一切都必须是确定性的。
工作流可以调用 Activity、等待信号、设置计时器以及进行分支,前提是这些分支条件仅取决于 Activity 的结果和工作流的输入。被禁止的行为包括模型调用、HTTP 请求、读取系统时钟、生成随机数,以及任何可能在另一台机器上的另一时刻产生不同结果的操作。所有这些功能都由 Activity 来承担。
可以将其理解为解剖结构。工作流运行的并非代理本身,而是围绕它的协调机制,而模型仅仅是代理会查询的服务。大脑是不可预测的,脊柱则必须稳定,而且大脑不会进入脊柱内部。
在真实代码库中的体现
上述简化的示例隐藏了一个细节。在真实的代码库中,LLM调用位于图的节点内部,而该图又运行在某个活动中,因此存在两个边界,而非一个:
Temporal Workflow (deterministic — replayed)
└── Activity: run_graph_until_interrupt (non-deterministic — recorded once)
└── LangGraph StateGraph
└── decide_node (async function)
└── get_llm_client().complete() ← the LLM call
该工作流本身纯粹是编排机制,仅由一系列 execute_activity 和 wait_condition 调用组成,没有其他内容。execute_activity 行构成了边界——活动内部发生的内容会被记录下来,而外部发生的内容则会被重新执行:
@workflow.defn
class SourcingWorkflow:
@workflow.run
async def run(self, brief: SourcingBriefInput) -> SourcingResult:
# ↓ This is the boundary. The activity runs once. Result is recorded.
graph_result = await workflow.execute_activity(run_graph_until_interrupt, brief, ...)
# Wait for human signal — a Temporal primitive, not an LLM call.
# On replay, the signal is injected from history.
await workflow.wait_condition(lambda: self._approval_received, timeout=timedelta(hours=24))
# ↓ Another boundary. Resume activity runs once. Result is recorded.
resume_result = await workflow.execute_activity(resume_graph, self._approval_data, ...)
# ↓ Side effects — each is its own activity with its own retry policy.
po_result = await workflow.execute_activity(create_po_activity, args=(...), ...)
其中没有任何部分会调用大语言模型客户端、发送HTTP请求、读取当前时间或生成随机数。每次都是相同的输入、相同的历史记录、相同的执行路径。
该活动会构建图结构并调用它,而那行graph.ainvoke()代码正是非确定性的来源:包括对大语言模型的调用、对工具的调用以及内存查询。该活动会返回一个字典,该字典会被记录下来,而重放时则会跳过整个过程:
@activity.defn
async def run_graph_until_interrupt(brief: SourcingBriefInput) -> dict:
graph = build_sourcing_graph(checkpointer=await get_checkpointer())
config = {"configurable": {"thread_id": activity.info().workflow_id}}
# ↓ Everything inside this call is non-deterministic. It runs ONCE.
# The result dict is recorded in the event history. On replay, it's injected.
final_state = await graph.ainvoke({"brief": brief, "suppliers": [], "attempts": 0, ...}, config)
return {"paused": True, "selected_supplier": selected, "suppliers": final_state.get("suppliers", [])}
在堆栈的底部,图节点是模型实际执行操作的所在。它会根据评分后的候选项及过往决策来构建提示语,随后发起调用:
async def decide_node(state: AgentState) -> dict:
scored = state.get("scored_suppliers", [])
messages = build_decide_prompt(brief.item, brief.quantity, brief.budget, scored, past_decisions)
# ↓ THE LLM CALL. This is the non-determinism that must never be in the workflow.
response = await get_llm_client().complete(messages=messages, temperature=0.0, max_tokens=1000)
selected, rationale = parse_llm_decision(response.content, scored)
return {"selected_supplier": selected, "decision_reason": rationale, "cost_incurred": response.cost_usd} # ↓ THE LLM CALL. This is the non-determinism that must never be in the workflow.
response = await get_llm_client().complete(messages=messages, temperature=0.0, max_tokens=1000)
这相当于在工作流层级中向下三层:活动、图结构、节点,再往下是大型语言模型。在回放时这些组件都不会运行,因为活动结果已被直接注入。该工作流是确定性的;而活动、图结构及模型本身则具有不确定性,但这些不确定性都被限制在活动层面之内。工作流始终只能看到已记录的词典,每次回放使用的都是同一个词典。
边界变得模糊的地方
理论上这种结构很清晰,但在实际应用中有些地方会变得模糊。多日运行的场景值得关注,因为其故障表现较为隐蔽,且两个持久化层可能会在毫无预警的情况下出现偏差。
当通过 LangGraph 的 interrupt() 方法使图表在某个审批节点处暂停时,该操作会完成并向工作流返回一个字典。随后工作流会调用 workflow.wait_condition() 来等待人工操作信号。此时,两个系统中会存在状态副本:
Temporal event history LangGraph checkpoint (Postgres)
└── ActivityCompleted(result) └── graph state at interrupt()
paused: True node: "approve"
selected: SupplierB selected: SupplierB
suppliers: [A, B, C] suppliers: [A, B, C]
两者都存储在 Postgres 中,但却是独立的表,由不同的代码路径在不同的时间进行数据写入。LangGraph 的检查点是在中断触发时由相关活动生成的;一旦该活动恢复执行,Temporal 会添加表示该活动已完成的事件。这样就形成了两次写入、两个系统以及一个逻辑状态。
当其中一个写入内容存活而另一个没有时,问题就出现了。有一种缓解方法是在操作层面处理:thread_id必须为Temporal的workflow_id,与run_id不同,它在多次重放过程中保持稳定;检查点表必须将数据保存与Temporal的历史记录相同的时长;此外,恢复操作必须调用graph.ainvoke(None, config),因为传递None会告诉LangGraph从检查点加载数据,而非从头开始重新运行。只要其中任何一点出错,各层状态就会偏离。工作流虽然不会崩溃,但会从错误的状态继续运行。
更好的解决方案:让图结构无状态
直观的设计使得 LangGraph 的 Postgres 检查点机制能够与 Temporal 的历史记录一起跟踪各个代理节点的状态。在多日暂停期间追踪故障模式可以发现隐藏的陷阱,即两个数据库之间典型的双重写入问题。如果在 LangGraph 写入检查点之后、Temporal 记录完成之前某个工作节点发生故障,状态引擎就会产生分歧,工作流就会在不一致的状态下悄悄继续运行。
与其不断为线程映射、数据保留和重新加载添加同步规则,更合理的做法是移除第二个状态引擎。没有了检查指针,LangGraph便变成了活动内部的一个内存型、无状态推理单元。Temporal通过workflow.wait_condition()来管理所有长期状态、执行历史以及需要人工干预的暂停环节。LangGraph只需执行其推理循环,将结果字典返回给活动即可结束工作。这样一来,既避免了双重写入,也消除了不同步的风险,同时让操作流程更加简单。
这两种工具并不会互相冗余。LangGraph在快速的内存图路由、工具调用以及节点级推理方面表现优异,而Temporal则在跨日的数据持久性、崩溃恢复以及分布式编排方面具有优势。将无状态的LangGraph嵌入到具有持久性的Temporal任务中,可以避免它们在数据持久性方面的竞争,从而保留两者的优点。
许多系统中的一个限制
编排框架只是重放契约出现的其中一个场景。任何通过重放来恢复的系统都包含相同的三个部分:事件记录、将事件转换为状态的纯函数,以及所有不可预测的内容,这些内容会被存储在已记录边界的另一侧。
代理程序让事情变得更加复杂。在传统的事件驱动账本中,apply函数始终是纯函数,因为给账户充值10美元总会产生相同的结果。而代理程序的业务逻辑是向大语言模型询问该执行什么操作,但每次得到的答案都不同。如果将决策逻辑放在重放路径中,就无法实现重放,因此该逻辑必须置于重放路径之外。
选择Temporal作为示例平台,是因为它能够强制执行契约:一旦违反规则,其SDK会抛出非确定性错误,而不会悄无声息地导致运行结果出现偏差。Step Functions则可免费实现这一功能,因其声明式的状态语言使得人们不可能意外地在状态机中加入LLM调用。而通过消息队列或自定义轮询方式,则需要手动进行约束检查。在轮询循环中,没有任何机制能阻止LLM调用,也不会对此发出任何警告。所有这些方法的约束条件都是相同的。唯一的问题是:谁来发现违规行为——是框架本身,还是凌晨3点正在值班的人?
其他智能体框架也采用同样的划分方式。有些框架会强制实施这种划分,比如Temporal和Microsoft Agent Framework。有些框架则提供相关基础功能,将具体实施交由开发者决定,如LangGraph和AutoGen。还有些框架则完全不考虑这一点,例如Celery和CrewAI。实际问题在于,有多少重放契约需要手动构建。
外部对比数据也指向了同样的结论。一篇关于将 Celery 迁移至 Temporal 的文章指出,重试时存在较高的重复执行风险,尤其是那些需要大量 GPU 时间的任务。另一个结合 CrewAI 的 Temporal 项目采用了上述架构,以调度引擎作为状态管理的核心,同时将代理框架作为无状态推理单元,在任务执行过程中被调用。在某次 AutoGen 与 CrewAI 的性能测试中,AutoGen 的 p95 延迟为 2.1 秒,而 CrewAI 为 4.7 秒;AutoGen 的任务完成成功率高达 94%,CrewAI 为 83%。这再次表明,框架处理故障的方式会通过数据体现出来。Microsoft Agent Framework 提供基于文件的检查点存储以及容错机制,以此实现同样的保障。
确定性带来的代价
活动边界并非完全自由。内联式的简洁性不复存在,因为错误版本行数更少,阅读起来更轻松。对状态的直接访问也会消失,因为一个活动只能看到传递给它的内容,而无法知晓工作流的内部结构。此外还会出现三种在内联式大型语言模型中不存在的边缘情况。这些都是实际的工程决策,而非哲学上的权衡。
边缘情况一:较长的活动与心跳间隔
调用大型语言模型可能需要30秒时间,而包含工具调用的多轮代理图则可能需要五分钟。Temporal的默认start_to_close_timeout值为10秒。如果某个活动的持续时间超过这个值,Temporal会将其标记为失败并重新尝试,这会导致模型调用再次发生,需再次支付令牌费用,并重新执行那些原本已被仔细隔离的非确定性操作。
解决办法是设置心跳超时机制。任何持续时间超过几秒的活动都应定期调用 activity.heartbeat(),以此向 Temporal 发送信号表明该活动仍在运行。如果工作进程崩溃,Temporal 会依据最后一次心跳信息来判断该活动完成了多少工作,这样支持检查点的活动就可以继续执行,而其他活动则需从零开始重新启动:
@activity.defn
async def run_graph_until_interrupt(brief: SourcingBriefInput) -> dict:
# Long-running: graph may run 5+ minutes with multiple LLM calls
for node in graph.stream(initial_state, config):
activity.heartbeat() # ← "I'm alive, don't timeout me"
# ... process node output
这里有个问题。心跳信号无法保存该任务的任何内部进度,它只能防止超时发生。如果工作进程在处理流程的中间阶段崩溃,重试时会从开头重新开始该任务,大语言模型也会再次被调用,因为结果只有在任务完成时才会被记录。对于时长为五分钟的流程,如果在四分钟时工作进程出错,那么四分钟内的未保护处理工作都会丢失。
这就是为什么示例将整个流程拆分为两个任务:一个任务运行到中断发生为止,另一个任务在中断后继续执行。这两个任务的时长都足够短,能在各自的超时时间内完成,且各自的结果都会被独立记录。这种拆分是为了实现更精细的结果保存,而非出于代码组织上的考虑。
第二种边缘情况:流式令牌
当大语言模型内嵌在工作流中时,令牌可直接传输到用户的HTTP响应中。而当它位于某个活动中时,令牌会进入该活动的输出部分,作为记录在历史中的单一返回值。直到该活动完成,才有内容传递给用户。
流式处理是针对这种模式最常被提出的反对意见,而且这一意见确实有道理。存在三种复杂度逐层递增的处理方式。第一种方式认为输出结果是一个决策而非聊天流,这种方式更适用于信息来源代理。第二种方式为多轮交互(如面试)添加了辅助通道,以便用户能够看到每一条回复。该辅助通道可以是基于 Redis 的服务器发送事件端点:系统将令牌发布到 Redis,端点再将这些令牌推送给用户,而 Temporal 仅保留最终结果。中途退出的用户可以稍后回来询问工作流的处理结果。流式处理中的令牌是一次性使用的;真正留存下来的则是存储的结果。
第三种边缘情况:重试策略不同
定时重试会自动处理失败的操作,但不同操作对失败的含义各不相同。只读查询与会产生副作用的操作不应以相同方式重试。对于创建采购订单这类会产生副作用的操作,其策略是严格禁止重试的,通过幂等键来确保安全性:
# Side effect — strict, non-retryable. Idempotency key handles safety.
po_result = await workflow.execute_activity(
create_po_activity,
args=(supplier, item, quantity, price),
retry_policy=workflow.RetryPolicy(
initial_interval=timedelta(seconds=1),
maximum_attempts=1, # ← don't retry. Idempotency key prevents duplicates.
non_retryable_error_types=["DuplicatePOError"],
),
)
# Verification — aggressive retry, but with backoff for eventual consistency
po_verification = await workflow.execute_activity(
verify_po_exists,
args=(po_result["po_id"],),
retry_policy=workflow.RetryPolicy(
initial_interval=timedelta(seconds=2), # ← give the DB time to sync
maximum_attempts=5,
),
)
重试策略是重放契约的一部分。当某个操作失败后进行重试时,这些重试操作会被记录在历史中,而在重放时Temporal会直接注入最终结果,而不会重新执行那些已经失败的尝试。不过,如果某个操作使用maximum_attempts=3来设置最大尝试次数,那么它将会有三次创建采购订单的机会;由于没有幂等性键,这就会生成三份订单。这两种机制相互配合:策略决定何时重试,而键则决定在重试时执行何种操作。
这些就是耐用性的代价。问题不在于确定性与非确定性,而在于它是通过某种间接方式实现耐用性,还是简单且无法恢复的。生产代理应当选择具备耐用性的方案,因为生产过程难免会出现故障。关于代理可靠性的讨论往往停留在错误的层面,只关注模型本身:改进提示词、增加评估覆盖率、下个季度开发更智能的检查点。但所有这些方法都存在极限。
唯一需要遵循的规则
在系统中的所有组件中,只有大语言模型能够抵抗重放攻击,正因如此它才被安置在不会受到重放影响的地方。Temporal的常见问题解答进一步强调了这一点:必须始终将大语言模型的调用封装在某个操作任务中。是“始终”,而非“通常”或“时间允许时”,因为唯一可以跳过这一步的情况是凌晨3点的系统崩溃,此时重放会导致不同的运行结果,而当班人员必须处理处于故障状态的代理程序。
由于缺乏重放契约,那三层结构——框架、循环和图谱——仅具有组织层面的作用。它们有助于思考系统设计,但无法确保系统正常运行。有了契约之后,这些结构便具备了实质性意义:循环会从上一次完成的步骤继续执行,相关操作会从历史记录中获取;图谱的状态被存储在固定位置,由恢复操作加载;由于结果已被记录,且幂等性键构成了第二道防护机制,因此无需重新运行相关工具。在出现故障时,这些层依然保持独立,这并非仅仅出于原则考虑。
仍存在一个限制。该机制只能保证工作流在崩溃后能够正确重放,却无法确保业务逻辑的重放安全性,这部分问题仍需由设计者解决。幂等性是接下来需要解决的问题。