01 / THE SIGNAL

我们发现了什么

AI 智能体的幂等键:为什么对参数进行哈希处理行不通。在上一篇关于为什么我们的智能体在崩溃后拒绝重新执行工作 (https://dev.to/anis_meziani_52aab42304a8/amnesia-and-deja-vu-how-our-ai-agents-failed-after-every-restart-and-how-we-fixed-it-5c80)的文章发布后,一位读者留下了一条评论,让讨论深入到了更高一个层面。

  • 来源:DEV Community(发现于 2026-10-22)
  • 证据等级:D · 发现产品或需求信号,暂未获得可核验的商业证据。
  • 商业模式:待核验
  • 主题:AI Agent
  • 初筛评分:16/100 · 收录 1 次
#工作流自动化#待验证#产品发现
02 / SOURCE & EVIDENCE

证据,比故事更重要。

发现产品或需求信号,暂未获得可核验的商业证据。

规则清洗与初筛,未经人工商业核验。原文语境、实际客户和付费情况仍需自行验证。

引用与数字披露

来源类型(原作者自述/第三方测算/媒体转引)需采集端标注,本版尚未落字段。

短句引用
作者
未标注
抓取日期
来源类型
未标注
数字口径
币种
未标注
口径
未标注
披露主体
未标注
披露日期
未标注

中文辅助译文(全文)

在我们上一篇关于为什么我们的智能体在崩溃后拒绝重做工作的文章之后,一位读者留下了一条评论,让讨论深入到了新的层次。

大致观点是:你无法干净地恢复一条非确定性执行轨迹,因此,真正可靠的东西只有效果边界处的幂等性。 随后,一个问题一直萦绕在我脑海中:为什么要让编排器决定哪些内容可以安全重放,而不是把这个决定下放到工具本身?

他们说得没错,而本文正是我尝试正式回答这个问题的文章。本文介绍了我们目前采取的做法、这个想法显而易见的版本为何在 LLM 面前会失效,以及我们正在逐步趋向的设计。先提前说明:最后一部分尚未发布。它只是一个提案,而我真心希望有人对它提出质疑。

摘要

  • 目前,ApowerB 会拒绝自动重放任何调用过任何工具的运行,包括只读工具。这种做法安全、直接,但运行一旦完成一半就会陷入停滞。
  • 修复方法是使用按工具划分的幂等键,这样重放时便能在工具处去重。但当参数由 LLM 编写时,对参数进行哈希并不奏效:重放可能会起草出一封略有不同的电子邮件,导致哈希值发生变化。
  • 始终有效的规则是:根据稳定的意图构建幂等键,绝不能根据生成的内容构建。
  • 我们已经在两个地方采用了这种做法,并在另外一些地方通过结构本身来强制执行“只读”。其余部分仍只是一份设计草案,并向所有人开放讨论。

我们目前所处的阶段:默认拒绝

当一次运行失败时,我们的运行时会记录它调用过哪些工具。如果调用过任何工具,系统都会拒绝自动重放,并返回一个列出这些工具的 409 响应(run_main.py)。不过,人工仍然可以强制重放。

我们不对工具进行分类。GET 与 send_email 一视同仁。听起来有些懒散,但请容我先为它辩护一下,因为读者自己提出的例子就是支持这种做法最好的论据:

  • 对一个受速率限制或具有状态的 API 执行 GET,重复调用并非没有代价;
  • 一个会写入审计记录的“纯”查询并不是纯查询;
  • 第三方 MCP 服务器中的工具可以自行声明为只读,而你没有办法核实这一点。

推断工具是否纯粹,恰好会在这些情况下判断错误,而这些正是会造成伤害的情况。拒绝所有操作只需要点击一下。猜错则会导致向客户重复发送一封电子邮件。

默认拒绝模式失效的地方

默认拒绝是安全的。但运行一旦完成一半,它也会陷入停滞。

设想一个电子邮件营销活动。我们的 email-campaign 技能已经在执行效果之前设置了人工审核关卡:它会起草所有内容,然后绝不未经明确批准就发送。一旦获得批准,它便会遍历收件人,并分别向每人发送一封电子邮件。

现在,假设 Pod 在发送 100 封邮件中的第 40 封后崩溃。

  • 重放会被拒绝,因为此前调用过工具。这样做正确,却毫无用处:60 个人永远收不到邮件。
  • 强制重放,则有 40 个人会收到两遍。

在运行层面没有好的答案。运行是错误的单元。电子邮件才是正确的单元。 读者的想法正是由此而来:让每个效果知道自己是否已经发生过。

显而易见的版本,以及它为何会失效

教科书式的方法是:每个会产生效果的工具根据其参数计算一个幂等键,例如 sha256(json(args)),运行时则跳过其键已经执行过的任何调用。

对于普通程序,这种方法有效。对于 LLM,它会悄无声息地失败,因为参数也不稳定。

第一次尝试时,模型调用:

{"to": "anna@acme.com", "subject": "Following up on your request",
"body": "Hi Anna, following up on the quote you asked for..."}

重放时,输入相同,智能体也相同:

{"to": "anna@acme.com", "subject": "Your quote",
"body": "Hello Anna, I'm getting back to you about your quote..."}

主题不同,正文不同,哈希也不同。账本从未见过这个键。Anna 会收到两封电子邮件,而你的幂等层却报告称一切进展顺利。

读者在上一个讨论串中说的一句话,在下一层同样适用:第 3 步并不是一个稳定的坐标。参数也不是。

规则:采用稳定意图,绝不采用生成内容

将每个参数分为三类:

类别示例是否包含在键中?
来自外部世界的身份标识事件 ID、消息 ID、线程 ID、记录 ID、规范化后的收件人地址是
根据输入确定性推导得出上传文件内容的哈希值是
由模型生成正文、主题、摘要、任何自由文本绝不

这个键回答的是“我是否已经因为这个原因、为这个目标做过这件事?”,而不是“我是否已经发送过这些完全相同的字节?”。对于营销活动:key = (triggering_event, tool, recipient)。重放时可以从头重写每封电子邮件;Anna 的键保持不变,因此会被跳过。

我们已经在两个地方采用了这种做法

这在我们的代码库中并非假设,只是尚未推广开来。

1. Webhook:键采用提供商的身份标识。 当 Microsoft Graph 重新投递通知时,(subscription_id, resource_id) 上的唯一索引会拒绝重复插入,消息只会被处理一次(outlook.py)。键中不包含任何生成的内容。它纯粹是外部世界的身份标识。

2. BI 数据集:键根据内容推导得出。 当智能体将文件导入为数据集时,数据集的 ID 并非随机生成。它是基于所有者、存放位置以及文件字节的 SHA-256 值计算出的 UUIDv5(bi_datasets.py):

digest = hashlib.sha256(attachment["content"]).hexdigest()
dataset_id = str(uuid.uuid5(
uuid.NAMESPACE_URL,
"|".join([owner.lower(), organization_id, project_id,
attachment["folder"], attachment["name"],
data.sheet or "", digest]),
))
existing = await store.get(dataset_id)
if existing is not None:
return {"success": True, "dataset_id": dataset_id, "reused": True}

对同一个文件运行两次:只会得到一个数据集。改动一个字节:就会得到一个新的数据集。注意键中不包含什么:格式化文本形式的显示名称。

这里也附带了这种做法随之而来的伤疤。如果用户删除了该数据集会怎样?软删除会保留该行,因此这个确定性 ID 仍被占用,插入操作会发生冲突。我们的代码会捕获 IntegrityError,然后回退到一个新的随机 ID。这就是一般性教训:幂等键的生命周期会长于它所指向的对象。 你的账本需要区分“已经完成”和“完成后又撤销”。

有些工具通过结构而非声明保证纯粹性

读者问题的另一半是分类。我们目前的答案是:我们不相信一个声称自己只读的工具。我们只相信一个无法写入的工具。

  • Oracle 工具会开启 SET TRANSACTION READ ONLY 并执行回滚(oracle.py)。
  • MongoDB 工具会拒绝包含写入阶段($out、$merge)的聚合管道(mongodb.py)。
  • Redshift 工具会在运行前验证 SQL 是否为只读(redshift.py)。

这些是我们唯一愿意标记为“可安全重放”的工具,而且仅仅是因为保证存在于工具代码中,而不是依赖旁边的某个标志。

我们正在逐步趋向的设计

这一部分是设计草案,并非已经发布的代码。 这是我们认为正确的形态,将其写下来是为了接受质疑。

1. 工具声明其效果,并默认采用最坏情况。

@tool(effect="none") # pure by construction, enforced in the tool
def oracle_run_query(sql: str): ...

@tool(effect="idempotent",
key=lambda args, ctx: (ctx.trigger_event_id, "send_email",
normalize(args["to"])))
def send_email(to: str, subject: str, body: str): ...

@tool # no declaration: treated as unsafe
def third_party_mcp_action(**kwargs): ...

2. 带有唯一约束的效果账本。 在 idempotent 工具运行之前,运行时会在账本表中查找它的键。如果已经存在,就返回存储的结果,而不执行工具。如果不存在,就执行工具,然后记录结果。这就是我们用于 Webhook 的同一种唯一索引技巧,并将其应用于每一种效果。

3. 未声明的工具仍采用默认拒绝。 任何没有声明的工具,包括所有第三方工具,都会保持当前行为:如果它运行过,那么该次运行就不会被自动重放。逐个将工具声明为 idempotent,是获得自动重放权限的方式。

4. 如果提供商支持,就将键一路传递过去。 一些 API 原生接受幂等键(Stripe 的 Idempotency-Key 请求头就是经典示例)。在这种情况下,该键应一路传递给提供商,这样即便系统在 API 调用与写入账本之间发生崩溃,也能在提供商一侧完成去重。

完成这些之后,在第 40 封邮件处崩溃的营销活动就能干净地重放:账本命中 40 次,发送 60 次,没有人收到两封电子邮件。

我还没有好答案的问题

以下这些地方,我真心希望得到帮助:

  • 每个工具应将哪些字段放入键中? 对于电子邮件,(event, recipient) 似乎合适。对于“将摘要发布到 Slack”,应该是 (event, channel) 吗?如果智能体针对同一事件,合法地向同一个人发送两封内容不同的电子邮件呢?
  • 如果重放走了不同的分支怎么办? 模型第二次可能会决定给另一组人员发送电子邮件。按目标划分的键可以处理重叠部分,但那些在第一次运行中收到邮件、却在第二次运行中被排除的人该怎么办?
  • 账本需要保留多长时间? 永久保留并不合适(涉及存储,而且“下个月向同一个人发送同一封电子邮件”也是合法需求)。保留时间过短,则会重新打开重复执行的窗口。
  • 效果执行与账本写入之间发生崩溃。 如果提供商不原生支持幂等性,就会存在空档。采用“事先记录意图,事后确认”,再加上对账机制,是否已经足够?

欢迎与我们一起构建

如果你已经发布过类似的东西,或者对上述任何问题有强烈见解 We'd love your help. 我们将就工具级幂等键开启一个 RFC issue,每位贡献者都会获得署名。

也感谢那位促成了这一切的读者。公开写作最棒的一点,就是评论区能把话题带到一个全新的方向。

你是如何为智能体工具构建幂等键的? 对参数进行哈希、使用稳定字段,还是采用其他方法?请在评论区告诉我。

译文由上游机器翻译生成,可能有误;判断请以英文原文为准。

英文原文(来源本站未改写)

After our last post on why our agents refuse to redo work after a crash, a reader left a comment that took the discussion a level deeper.

The point, roughly: you can't cleanly resume a non-deterministic trajectory, so the only thing that really holds is idempotency at the effect boundary. And then the question that stuck with me: why make the orchestrator decide what's safe to replay, instead of pushing that decision down to the tools themselves?

They're right, and this post is my attempt to answer properly. It covers what we do today, why the obvious version of that idea breaks with LLMs, and the design we're converging on. Fair warning: the last part isn't shipped. It's a proposal, and I'd genuinely like it torn apart.

TL;DR

  • Today, ApowerB refuses to auto-replay any run that called any tool, reads included. Safe, blunt, and stuck the moment a run completes halfway.
  • The fix is per-tool idempotency keys, so a replay dedupes at the tool. But hashing the arguments doesn't work when an LLM writes them: the replay drafts a slightly different email, and the hash changes.
  • The rule that holds: build the key from stable intent, never from generated content.
  • We already do this in two places, and enforce "read-only" by construction in a few others. The rest is a design sketch, and an open invitation.

Where we are today: default-deny

When a run fails, our runtime records which tools it called. If it called anything, an automatic replay is refused with a 409 that lists the tools (run_main.py). A human can still force it.

We don't classify tools. A GET counts the same as a send_email. That sounds lazy, so let me defend it for a second, because the reader's own examples are the best argument for it:

  • a GET against a rate-limited or stateful API is not free to repeat;
  • a "pure" lookup that writes an audit row isn't pure;
  • a tool from a third-party MCP server can call itself read-only, and you have no way to check.

Inferring purity gets exactly these cases wrong, and they're the ones that hurt. Refusing everything costs a click. Guessing wrong costs a duplicate email to a customer.

Where default-deny breaks

Default-deny is safe. It is also stuck the moment a run finishes halfway.

Picture an email campaign. Our email-campaign skill already puts a human gate in front of the effect: it drafts everything, then never sends without explicit approval. Once approved, it loops over the recipients and sends one email each.

Now the pod dies after email 40 of 100.

  • Replay is refused, because tools were called. Correct, and useless: 60 people never get their email.
  • Force the replay, and 40 people get it twice.

There is no good answer at the run level. The run is the wrong unit. The email is the unit. That's exactly where the reader's idea comes in: let each effect know whether it already happened.

The obvious version, and why it breaks

The textbook approach: every effectful tool computes an idempotency key from its arguments, say sha256(json(args)), and the runtime skips any call whose key it has already executed.

With a normal program, that works. With an LLM it quietly fails, because the arguments aren't stable either.

First attempt, the model calls:

{"to": "anna@acme.com", "subject": "Following up on your request",
 "body": "Hi Anna, following up on the quote you asked for..."}

Replay, same input, same agent:

{"to": "anna@acme.com", "subject": "Your quote",
 "body": "Hello Anna, I'm getting back to you about your quote..."}

Different subject, different body, different hash. The ledger has never seen this key. Anna gets two emails, and your idempotency layer reports that everything went perfectly.

The reader's line from the last thread applies one level down: step 3 isn't a stable coordinate. Neither are the arguments.

The rule: stable intent, never generated content

Split every argument into three kinds:

KindExamplesIn the key?
Identity from the outside worldevent id, message id, thread id, record id, normalized recipient addressYes
Derived deterministically from inputshash of an uploaded file's contentYes
Generated by the modelbody, subject, summary, any free textNever

The key answers "have I already done this thing, for this reason, to this target?", not "have I already sent these exact bytes?". For the campaign: key = (triggering_event, tool, recipient). The replay can rewrite every email from scratch; Anna's key is the same, so she's skipped.

Two places where we already do this

This isn't hypothetical in our codebase. It's just not generalized yet.

1. Webhooks: the key is the provider's identity. When Microsoft Graph re-delivers a notification, a unique index on (subscription_id, resource_id) rejects the duplicate insert, and the message is processed once (outlook.py). Nothing generated goes into that key. It's pure outside-world identity.

2. BI datasets: the key is derived from the content. When an agent imports a file as a dataset, the dataset's ID isn't random. It's a UUIDv5 over who owns it, where it lives, and a SHA-256 of the bytes (bi_datasets.py):

digest = hashlib.sha256(attachment["content"]).hexdigest()
dataset_id = str(uuid.uuid5(
    uuid.NAMESPACE_URL,
    "|".join([owner.lower(), organization_id, project_id,
              attachment["folder"], attachment["name"],
              data.sheet or "", digest]),
))
existing = await store.get(dataset_id)
if existing is not None:
    return {"success": True, "dataset_id": dataset_id, "reused": True}

Run it twice on the same file: one dataset. Change one byte: a new one. Notice what's not in the key: the display name, which is formatted text.

And here's the scar that comes free with it. What if the user deleted that dataset? Soft deletes keep the row, so the deterministic ID is still taken, and the insert collides. Our code catches the IntegrityError and falls back to a fresh random ID. That's the general lesson: an idempotency key outlives the thing it points to. Your ledger needs to know the difference between "already done" and "done, then undone".

And some tools are pure by construction, not by declaration

The other half of the reader's question was classification. Our answer so far: we don't trust a tool that says it's read-only. We trust one that can't write.

  • The Oracle tool opens a SET TRANSACTION READ ONLY and rolls back (oracle.py).
  • The MongoDB tool refuses aggregation pipelines with a write stage ($out, $merge) (mongodb.py).
  • The Redshift tool validates that the SQL is read-only before it runs (redshift.py).

These are the only tools we'd be comfortable marking "safe to replay", and only because the guarantee lives in the tool's code, not in a flag next to it.

The design we're converging on

This part is a sketch, not shipped code. It's the shape we think is right, written down so it can be challenged.

1. Tools declare their effect, and default to the worst case.

@tool(effect="none")            # pure by construction, enforced in the tool
def oracle_run_query(sql: str): ...

@tool(effect="idempotent",
      key=lambda args, ctx: (ctx.trigger_event_id, "send_email",
                             normalize(args["to"])))
def send_email(to: str, subject: str, body: str): ...

@tool                            # no declaration: treated as unsafe
def third_party_mcp_action(**kwargs): ...

2. An effects ledger, with a unique constraint. Before an idempotent tool runs, the runtime looks its key up in a ledger table. Already there: return the stored result, don't execute. Not there: execute, then record. The same unique-index trick as our webhooks, applied to every effect.

3. Undeclared stays default-deny. Anything without a declaration, including every third-party tool, keeps today's behavior: if it ran, the run isn't auto-replayed. Declaring a tool idempotent is how you earn automatic replay, one tool at a time.

4. Pass the key through when the provider supports it. Some APIs accept an idempotency key natively (Stripe's Idempotency-Key header is the classic example). When they do, the key should travel all the way to the provider, so even a crash between the API call and our ledger write dedupes on their side.

With that in place, the campaign that died at email 40 replays cleanly: 40 ledger hits, 60 sends, nobody gets two emails.

The questions I don't have good answers to yet

This is where I'd genuinely like help:

  • Which fields go in the key, per tool? For email, (event, recipient) feels right.For "post a summary to Slack", is it (event, channel)?What about an agent that legitimately sends two different emails to the same person for the same event?- What if the replay takes a different branch? The model may decide to email a different set of people the second time.Per-target keys handle overlap, but what about the people who were emailed in run one and dropped in run two?- How long does the ledger remember? Forever is wrong (storage, and "the same email next month" is legitimate).

Too short reopens the duplicate window. - The crash between the effect and the ledger write. If the provider has no native idempotency, there's a gap.Is "record intent before, confirm after" plus reconciliation good enough?

Come build it with us

If you've shipped something like this, or you have strong opinions about any of the questions above, we'd love your help. We're opening an RFC issue on tool-level idempotency keys, and every contributor is credited by name.

And thanks to the reader whose comment started all of this. The best part of writing in public is when the comments take the topic somewhere new.

How do you build idempotency keys for your agent's tools? Hash the args, stable fields, something else? Tell me in the comments.

出处https://dev.to/anis_meziani_52aab42304a8/idempotency-keys-for-ai-agents-why-hashing-the-arguments-doesnt-work-cap抓取日期 · 采集源 DEV Community

03 / EVIDENCE GAPS

这条还缺什么证据?

下面每条都由本条已有字段推出(等级、理由、商业模式、来源次数、是否演示), 本站不生成推测性结论;通用验证方法放在方法论页。

  • 可核验的收入或付费证据查官网定价页与付费口径;第三方数据源(如 GetLatka)只作旁证,需标注来源与时点。
  • 商业模式未定确认按席位/按用量/授权还是开源托管版收费;开源项目另查 LICENSE 与是否存在付费版。
  • 只有单一来源找一手站点或其他渠道是否重复出现同一产品;社区热帖数量不等于商业进展。

通用验证清单(谁有这个问题/谁愿意付费/一个人能交付哪一小步)见我们的筛选方法。

04 / SIGNAL HISTORY

发现时间线