首页
学习
活动
专区
圈层
工具
发布
社区首页 >专栏 >at-most-once 是怎么落到数据库里的:一次工具调用在网关、账本、工作流三层的计数

at-most-once 是怎么落到数据库里的:一次工具调用在网关、账本、工作流三层的计数

原创
作者头像
用户5195948
发布于 2026-09-21 10:05:59
发布于 2026-09-21 10:05:59
1080
举报

「同一次工具调用不会被重复执行」,这句话在任何一个 Agent 运行时里都得有个交代。交代的方式无非两种:要么在调用边界上不重试,要么在持久层上去重。

我们两样都做了,但做的位置和多数人的直觉相反:有幂等键的调用被钳成只试一次,没有幂等键的反而允许重试。 真正保证「副作用不重复」的不是那个重试钳,而是一张带租约的工具调用表——它有 outbound_started_at 这个字段,用来区分「还没出网」和「出过网但不知道结果」,后者会被标成 in_doubt 而不是重跑。

这篇沿着一次工具调用从进网关到落账本的完整路径,把三层计数逐个拆开:策略网关的尝试数、账本的 attempt_count、工作流节点的 retry_policy,以及它们互相看不见的那几处。全部是读代码的结论,附文件行号。

先看结论

问题:在我们的 Agent 运行时里,一次工具调用到底会被执行几次?

答案:当前装配下恰好一次。 而策略网关的构造函数上写着 max_retries: int = 2。

这两者不矛盾,是三个原因叠出来的:

  1. 那个 2 在产品里到不了。 四个装配点全都带着 trace_writer,于是每次调用都必然拿到一个幂等键,而网关看见幂等键就把尝试数钳到 1。
  2. 就算到得了,它数的也不是「重试 2 次」。 共享助手把这个参数传给 stop_after_attempt,tenacity 数的是尝试次数——2 意味着一共调 2 次。
  3. 就算真重试了,也几乎触发不了。 三个工具适配器都把异常咽成了 ToolResponse(success=False),而 tenacity 只对抛出来的异常重试。

真正保证「同一次副作用不会发生两遍」的,不是上面这个重试钳,而是下面一层的持久化工具调用账本:一张带租约、带 outbound_started_at 的表。

顺便说一句对照:同一个仓库里,事件外发那条边界的默认值是至少一次、上限 64 次。

下面是三层的完整拆解。这不是一份 bug 清单——大部分是有意的设计,只有命名和文档确实对不上,我在第十五节一并列了出来。

一、这篇的起点是一条读者评论

2026-09-07,我们那篇讲「框架与运行时是两层」的英文稿下面,reidmarlow(Reid Marlow)留了一条评论。除了认同「端口层把已解析参数和已脱敏参数分开是这套架构里最干净的部分」之外,他补了一条我们自己没写出来的论据,大意是:

把重试上限下沉到策略网关,还顺带解决了另一个问题——当下游服务不支持幂等键时, 超时重试会把外部副作用重复触发一遍。

这是一条好论据。它也是我写这篇的原因:我想在回帖之前先确认,我们的代码是不是真的按他理解的那样在工作。

核完的结果是:不是。方向正好相反。

我们的网关不是「下游没有幂等键 ⇒ 少重试」,而是「这次调用带着幂等键 ⇒ 只试一次」。没有幂等键的那条路,反而保留了那个 max_retries 的默认值。

他的结论(副作用不会被重复触发)在我们这儿仍然成立,但成立的原因在另一层。把这件事讲清楚,需要把三层都数一遍。

二、第一层:网关里的那行三目运算符

工具策略网关在真正调用适配器之前,做了一串事:解密钥、领账本上的claim、查出网策略、查限流与配额,然后才进入这一段:

代码语言:plaintext
复制
response = await run_with_timeout_retry(
    _invoke,
    timeout_seconds=timeout_seconds,
    # Durable Agent calls are at-most-once at this boundary.
    # Not every downstream adapter can honor an idempotency key.
    max_retries=1 if kwargs.get("idempotency_key") else self.max_retries,
    timeout_factory=lambda: TimeoutError(
        f"Tool invocation timed out after {timeout_seconds} seconds",
        {"timeout_seconds": timeout_seconds, "tool_ref": tool_ref},
    ),
    wait_min=1,
    wait_max=5,
)

(server/app/kernel/ports/tools/policy.py:349–361)

那两行注释把意图写得很清楚:持久化的 agent 调用在这条边界上是至多一次的,因为并不是每个下游适配器都认幂等键。

也就是说,这个三目运算符里的 idempotency_key 不是一个安全信号,而是一个路径标记:它标的是「这次调用走的是有账本的那条路」。这与 Reid 的读法(把它当成下游能力的信号)是两件不同的事,而这个差别正好决定了钳的方向。

三、max_retries 数的是尝试次数,不是重试次数

五个端口——工具、存储、向量、密钥、插件——共用同一个助手:

代码语言:plaintext
复制
async def run_with_timeout_retry(
    operation, *, timeout_seconds, max_retries, timeout_factory,
    wait_multiplier=1, wait_min=1, wait_max=10,
):
    @retry(
        stop=stop_after_attempt(max_retries),
        wait=wait_exponential(multiplier=wait_multiplier, min=wait_min, max=wait_max),
    )
    async def _with_retry():
        return await operation()

    try:
        return await asyncio.wait_for(_with_retry(), timeout=timeout_seconds)
    except TimeoutError:
        raise timeout_factory()

(server/app/kernel/ports/common/policy.py:52–74)

关键在 stop_after_attempt。tenacity 的这个停止条件数的是尝试:stop_after_attempt(2) 是「一共调两次」,也就是一次重试。参数名叫 max_retries,docstring 写的是 "Maximum retries",但传进去的数字实际是尝试次数上限,比名字少一次。

这不是我推的,是我们自己的单元测试已经把它钉住了:

代码语言:plaintext
复制
result = await run_with_timeout_retry(
    operation, timeout_seconds=5, max_retries=2, ...
)
assert result == "ok"
assert attempts == 2

(server/tests/unit/test_port_policy_common.py:32–52)

于是有一个直接后果:max_retries=1 的意思是「只试一次、不重试」。 而仓库里传字面量 1 的地方有六处:

位置

操作

实际语义

ports/secrets/policy.py:35

取一个密钥

不重试

ports/plugins/policy.py:112

调一个插件工具

不重试

ports/vector/policy.py:93

确保集合存在

不重试

ports/storage/policy.py:338

查对象是否存在

不重试

ports/storage/policy.py:403

生成下载 URL

不重试

ports/storage/policy.py:441

生成上传 URL

不重试

这六个恰好都是只读或天然幂等的操作——正是最适合重试的那一类。我不认为这是有意为之;我倾向于认为写的人以为 1 是「重试一次」。

存储端口的默认值是 max_retries: int = 3(即三次尝试),向量端口是 2,工具端口是 2。docstring 都写着 "Maximum retries"。

四、于是产品里的答案是 1

回到那行三目运算符:什么时候 kwargs 里会有 idempotency_key?

网关自己会放一个进去。只要构造时带了 trace_writer,这段就会执行:

代码语言:plaintext
复制
tool_call_id = str(kwargs.get("tool_call_id") or f"call_{generate_ulid()}")
idempotency_key = str(
    kwargs.get("idempotency_key") or f"tool:{run_id}:{tool_call_id}"
)

(policy.py:261–264;随后在 :298–303 被写回 kwargs)

于是问题变成:产品里的工具端口,有没有可能不带 trace_writer?

装配代码只有一个地方造这个网关(app/wiring/container.py:487),而它的四个调用点全都传了一个当场 new 出来的 TraceWriter:

  • app/wiring/services.py:415–416(agent 应用服务)
  • app/api/v1/agent/dependencies.py:47–49(HTTP 依赖注入)
  • app/modules/workflow/runtime/engine.py:558–561 与 :606、:693–696(工作流引擎)

没有一条产品路径会让 trace_writer 是 None。 所以 self.max_retries 那条分支在当前装配下到不了,那个默认值 2 是一段目前无法抵达的代码。

而且这是有意的,测试的名字就写着:

代码语言:plaintext
复制
async def test_tool_policy_does_not_retry_a_durable_agent_invocation(request_ctx):
    ...
    policy = ToolPolicyGateway(gateway=SideEffectingTool(), ctx=request_ctx,
                               max_retries=2, enable_egress_check=False)
    with pytest.raises(RetryError):
        await policy.invoke(..., idempotency_key="agent-tool:run_side_effect_once:call_once")
    assert attempts == 1

(server/tests/unit/test_port_policy_enforcement.py:375–399)

显式传了 max_retries=2,断言的却是 attempts == 1。这是一个被测试守住的 at-most-once 边界,不是疏忽。

补一句:这个网关的 timeout_seconds 与 max_retries 没有任何配置入口—— 装配处没传,settings.py 里也没有任何 tool_ 开头的配置项。想改只能改代码。(限流和配额倒是有入口,它们来自请求上下文的 ctx.tool_rate_limit_per_minute 与 ctx.tool_daily_quota。)

五、就算那个 2 能到,也几乎触发不了

tenacity 的 @retry 只对抛出来的异常重试。而我们的三个工具适配器都不抛:

代码语言:plaintext
复制
# adapters/tools/http.py:97–101
except httpx.HTTPError as e:
    return ToolResponse(result=None, success=False, error=str(e))

# adapters/tools/function.py:41–42
except Exception as exc:
    return ToolResponse(result=None, success=False, error=str(exc))

# adapters/tools/mcp.py:323–325
except Exception as exc:
    logger.warning("MCP tool invocation failed for %s: %s", tool_ref, type(exc).__name__)
    return ToolResponse(result=None, success=False, error=str(exc))

httpx.HTTPError 是 httpx 那一层错误的基类,连接失败、读超时、raise_for_status() 抛的 4xx/5xx 全在里面。它们全部变成了返回值,而不是异常。

结论:HTTP 503、连接超时、MCP 服务器崩溃——这些最典型的「值得重试一次」的瞬时故障,在这条链路上一次都不会被重试,哪怕走的是没有幂等键的那条路。

那么什么会被重试?剩下的是工具路由器抛出来的那些:

代码语言:plaintext
复制
raise ValidationError(f"Tool not registered for tenant/workspace: {tool_ref}")   # router.py:364
raise ValidationError(f"HTTP tool '{tool_ref}' missing url (tool_spec.http.url)") # router.py:454
raise ValidationError(f"Function tool '{tool_ref}' missing entrypoint")          # router.py:512
raise ForbiddenError(...)                                                        # router.py:301

也就是配置错和权限错——重试一万次也不会变。 而它们之间还会按 wait_exponential(min=1, max=5) 等上 1 到 5 秒。

对照一下 LLM 端口就知道这不是必然:那边有一个 _is_retryable,并且有一个测试专门断言校验类错误不重试(test_validation_errors_are_not_retried,test_port_policy_enforcement.py:236–250,断言 await_count == 1)。工具端口没有这个判别函数。

六、超时是整段的预算,不是每次尝试的

再看一眼第三节那段代码的最后:

代码语言:plaintext
复制
return await asyncio.wait_for(_with_retry(), timeout=timeout_seconds)

wait_for 包住的是整个重试序列,不是单次尝试。所以工具端口那个默认的 30 秒,是「所有尝试 + 尝试之间的退避」的总预算。

这意味着:如果第一次尝试卡满 30 秒,第二次尝试根本不会发生——超时会先到,然后被 timeout_factory() 换成我们自己的 TimeoutError 抛出去。换句话说,那条本来就难触发的重试路径,只在「第一次尝试快速失败」时才有空间。

参数名是 timeout_seconds,工具网关的 docstring 写的是 "Request timeout"(policy.py:75)。它不是 request timeout,是 total timeout。

对照:LLM 端口的流式路径是在每次尝试内部各自 wait_for 的(ports/llm/policy.py:932–937),那才是 per-attempt 超时。同一个仓库,两种形状。

七、失败离开网关时,已经不是原来的异常了

tenacity 的 @retry 默认 reraise=False:停止条件满足而最后一次尝试仍失败时,它抛的是 RetryError,原异常包在里面。

注意这和重试次数无关:即便 stop_after_attempt(1),第一次尝试失败也会被包成 RetryError。所以在当前装配(永远走 at-most-once 分支)下,工具网关抛出的每一个失败都是 RetryError,不是 ValidationError、不是 ForbiddenError。

仓库里有一个 unwrap_retry_error 专门把它剥开(ports/common/policy.py:38–49),但它只被 error_details 用于写 trace 细节—— 剥开的是记账用的那一份,真正往上抛的那个异常没有被换回去。

这个行为已经被两个单元测试固化成断言:test_port_policy_enforcement.py:391 与 test_tool_secret_injection.py:215,两处都是 with pytest.raises(RetryError)。

影响很具体:任何上游写 except ValidationError: 来把「工具没注册」翻译成 400 的代码,在这条路径上都接不住。

八、第二层:账本。tool_call_id 是身份,idempotency_key 是断言

网关在调用适配器之前,会先去一张表上领一个 claim:run_step_tool_calls。

这张表上有三个唯一约束(kernel/runtime/db/models/runs.py:185–203),和这里相关的是后两个:

约束名

列

uq_run_step_tool_calls_scope_run_call

tenant + workspace + run_id + tool_call_id

uq_run_step_tool_calls_scope_idempotency

tenant + workspace + idempotency_key

但查重时用的只有第一个:_call_statement 按 tenant + workspace + run_id + tool_call_id 去找已有记录(runtime/runs/tool_calls.py:172–180)。

idempotency_key 在这里的角色是断言而不是键:找到已有记录之后,如果它的 tool_ref 或 idempotency_key 和这次不一样,直接抛 ConflictError("Tool call identity was reused with different input")(tool_calls.py:215–219)。参数本身也要一致——比对的是 canonical_request_hash(arguments)(写入在 :353,比对在 :236)。

默认的键形如 tool:{run_id}:{tool_call_id},所以在默认路径上两个约束不会打架。

还有一处值得注意:存进账本的是脱敏参数(policy.py:283 传的是 redacted_parameters),密钥引用只留 secret_id,不落明文。

九、at-most-once 是怎么落到磁盘上的

真正的保证在这三个字段上:status、lease_expires_at、outbound_started_at。

顺序是这样的:

  1. claim() 写一行 status="claimed"、attempt_count=1、租约归本 worker,租约长度 max(60, ceil(timeout) + 10) 秒(policy.py:274)。
  2. mark_running() 在跨出网关之前把 outbound_started_at 写上(tool_calls.py:473–495)。这一笔是整套机制的支点。
  3. 真正调用适配器。每次尝试开头会 renew_lease() 续租(policy.py:328–329)。
  4. complete() 落终态:status = "succeeded" if response.success else "failed"。

如果进程在第 3 步崩了,另一个 worker 过来重新 claim 时会看到一条租约已过期的记录,于是分两种情况:

  • outbound_started_at 是空的 ⇒ 请求没出过门,安全,直接重新 claim,attempt_count += 1(tool_calls.py:322–332)。
  • outbound_started_at 有值 ⇒ 我们出过门但不知道结果。这时不重跑,把记录标成 in_doubt,把步骤标成 paused,抛 ConflictError("Tool call outcome is in doubt")(tool_calls.py:297–320)。

这才是「副作用不会被重复触发」的真正来源。 它不依赖下游支不支持幂等键,也不依赖网关重试几次——它依赖的是「出网这件事本身在数据库里有一条记录」。

Reid 那条论据在我们这儿之所以结论成立,是因为这一层,不是因为那个三目运算符。

十、重放与重跑之间只差一个布尔

已经有终态的记录被再次 claim 时,默认行为是回放:

代码语言:plaintext
复制
if existing.status in {"succeeded", "failed"}:
    ...
    return ToolExecutionClaim(
        record=existing, run_step=step, replayed=True,
        cached_response=ToolResponse(
            result=payload.get("result"),
            success=existing.status == "succeeded",
            error=existing.error_message,
            metadata={..., "idempotent_replay": True},
        ),
    )

(tool_calls.py:270–289)

注意 {"succeeded", "failed"} 两个都在里面:失败也会被原样回放。网关拿到 replayed=True 就直接返回缓存,连适配器都不进(policy.py:291–296)。

要让它真的重跑,得显式传 retry_failed=True:那时它会清掉结果、attempt_count += 1、把步骤标成 retrying,重新开始(tool_calls.py:249–269)。

而这个布尔在两条执行路径上是不一样的:

路径

传不传 retry_failed

后果

工作流节点

传 True(executors/tool.py:374、executors/http.py:55、executors/node.py:100)

节点重试会真的重发那次工具调用

agent 循环

不传(modules/agent/runtime/executor.py:29–42)

失败的调用永远回放失败,不重发

两边的注释都写得挺清楚(工作流那边写着「身份是跨尝试稳定的,所以节点重试会落到上一次的记录上:成功就回放,失败就重跑」),但这个差异本身没有写进任何文档。

十一、第三层:工作流节点,以及同一个名字的三种语义

工作流引擎自己还有一层重试:

代码语言:plaintext
复制
retry_policy = node.get("retry_policy") or policy.get("default_retry_policy") or {}
max_retries = int(retry_policy.get("max_retries", 0) or 0)
...
attempts += 1
if attempts > max_retries:
    final_error_recorded = True
    raise

(modules/workflow/runtime/executor.py:427–428 与 :483–485)

这里的 max_retries 数的是重试次数:默认 0 就是不重试,写 2 就是最多跑 3 次。每一次重试还会新建一个步骤,step id 后缀是 _retry{attempt}(executor.py:380)。

于是同一个名字在三处是三种意思:

位置

写法

max_retries=2 时总共执行几次

工具端口(经共享助手)

stop_after_attempt(max_retries)

2 次

LLM 端口

for attempt in range(route.max_retries + 1)

3 次

工作流节点

if attempts > max_retries: raise

3 次

LLM 那一行在 ports/llm/policy.py:921,而它的次数同样有测试钉着:test_max_retries_counts_additional_attempts 用 max_retries=2 断言 port.chat.await_count == 3(test_port_policy_enforcement.py:219–232)。

也就是说:工具端口是三者里唯一那个「少一次」的,而它恰好是唯一一个会产生外部副作用的端口。往好处想,这个方向至少是安全的那一侧。

十二、下游看到几次、账本记了几次、账单数了几次

这三个数不一定相等。

幂等键确实往下游传了,但只在四个方法上:

代码语言:plaintext
复制
idempotency_key = kwargs.get("idempotency_key")
if idempotency_key and method.upper() in {"POST", "PUT", "PATCH", "DELETE"}:
    headers.setdefault("Idempotency-Key", str(idempotency_key))

(adapters/tools/http.py:34–36)

GET 不带——合理,但意味着「我们把幂等键传下去了」这句话得跟一句「除了 GET」。

MCP 适配器自己还会再试一次。 当 MCP 服务器回 401/403 并带着 WWW-Authenticate 挑战时,适配器会用挑战里的 scope 重新取 token 再调一次(adapters/tools/mcp.py:77–94)。这个行为有测试断言:assert len(factory.calls) == 2(tests/unit/test_mcp_official_sdk_adapter.py:272–312)。

那次重试对上层是不可见的:网关那边只算一次调用,账本上的 attempt_count 还是 1,而 MCP 服务器那边看到的是两次会话。

限流和配额在重试之前只查一次。 那两次 check_rate_limit 在 policy.py:311–325,而重试发生在 :349。所以在那条(目前到不了的)非持久路径上,真打了两次下游,配额只扣一次。

计费同样只记一次:record_cost(..., billed_quantity=1, ..., request_count=1)(policy.py:419–430),每次 invoke 一笔。

平台在另一个地方已经认真想过同一类问题,那段 docstring 值得整段抄过来:

代码语言:plaintext
复制
llm_image_max_retries: int = 0
"""
Retry ceiling for image generation, capping the per-route retry budget.

Image generation is billed per generated image and is not idempotent: a
platform-side timeout does not cancel the provider-side generation, so a
retry can bill the workspace again while the platform records a single
usage fact. Zero keeps recorded cost aligned with provider charges.
"""

(settings.py:240–247)

「平台侧的超时并不会取消提供商侧的生成,所以一次重试可能让工作区被再收一次费,而平台只记了一次用量。」——这段话原样搬到工具调用上也成立,只是工具那边没有对应的说明,也没有对应的开关。

十三、另一条边界的默认值是相反的

同一个仓库里,事件外发那条边界的选择正好反过来:

代码语言:plaintext
复制
max_dispatch_attempts: int = 64
...
next_attempt = int(row_fresh.attempt_count or 0) + 1
if next_attempt >= self.max_dispatch_attempts:
    await self.repo.mark_failed(row_id, msg, consumer_name=consumer_name)
else:
    await self.repo.mark_retry(row_id, msg)

(kernel/events/dispatcher.py:50 与 :76–88)

至少一次,上限 64 次,退避由 outbox 仓储做。去重的责任交给消费端:一张 (consumer_name, event_id) 的 checkpoint 表,靠唯一约束顶住重复(kernel/events/checkpoint.py,插入冲突就返回 False 当作已处理)。

这个对比本身是合理的:事件是自家的、可重放的、消费者能做幂等;工具调用是别人家的、不可撤销的。两条边界选相反的默认值恰恰说明这是想过的。

但有一件事对不上:README 里明确写了事件那条("provides at-least-once delivery; consumers remain responsible for idempotent side effects",README.md:77),而工具那条 at-most-once 的契约,一个字都没写进文档——它只活在 policy.py:352–353 的两行注释和一个测试的名字里。

十四、那么那条评论说的事,到底成不成立

分三句话回答:

  1. 方向相反。 我们不是「下游不支持幂等键所以少重试」,而是「这次调用有幂等键(=走的是有账本那条路)所以只试一次」。
  2. 结论成立。 副作用确实不会被重复触发。
  3. 但靠的是另一层。 靠的是那张带 outbound_started_at 的表,以及它在「出过门但不知道结果」时选择 in_doubt 而不是重跑。

如果你在自己的系统里做类似的事,我会把这次核对的收获写成两条:

  • 把「重试几次」和「幂等键在不在」解耦。 幂等键是下游能力的信号,重试预算是自己的风险偏好,用一个三目运算符把两者绑在一起,读代码的人九成会读反——这次就有一位读者读反了,而且他读的方向比我们代码里的更符合直觉。
  • 真正的防重复要落在一张表上,而不是一个计数器上。 计数器只能决定你试几次,表才能告诉你「上一次到底出没出门」。

十五、现在还对不上的十二个地方

按老规矩,自己查出来的先自己列。每条都写清影响和现在能怎么绕。

① max_retries 数的是尝试次数,而名字和 docstring 都说是重试次数。 五个端口共用这个助手,六处传字面量 1(见第三节表格),实际语义是「不重试」,而这六处恰好都是只读或幂等操作。影响:读代码的人会高估系统的重试能力。绕法:把它当 max_attempts 读。打算提一个 issue。

② 工具边界的 at-most-once 契约只写在两行代码注释和一个测试名字里。 README 写了事件的 at-least-once,没写工具的 at-most-once。影响:自托管的人无法从文档判断「我的工具调用会不会被重发」。打算提一个 issue。

③ 三个适配器把异常咽成 success=False,于是网关的重试对它们全部失效;而路由器抛的配置类错误反倒会被重试。 影响:最值得重试的瞬时故障不重试,最不值得重试的配置错误白等 1–5 秒退避。绕法:目前走的都是 at-most-once 分支,所以实际影响只剩「重试语义名存实亡」。

④ 失败离开网关时是 RetryError,原异常类型丢在里面。 unwrap_retry_error 只用于 trace 细节,没有用在往上抛的那个异常上;两个单测把这个行为固化成了断言。影响:上游按异常类型做 HTTP 状态映射的代码接不住。打算提一个 issue。

⑤ timeout_seconds 包住整段重试序列,而 docstring 叫它 "Request timeout"。 影响:第一次尝试吃满预算时,第二次尝试不会发生,而读参数名看不出这一点。对照 LLM 流式路径是 per-attempt 超时,同仓库两种形状。

⑥ 工具网关的超时与尝试数没有任何配置入口。 装配处不传,settings.py 里没有任何 tool_ 开头的项。影响:自托管想调这两个值只能改代码。打算提一个 issue。

⑦ 🔍 推论,不是观测:HTTP 适配器把 timeout=timeout_s 显式传给 httpx,而这个 timeout_s 只有在工具规格里声明了 policy.timeout_ms / http.timeout_ms 时才有值(adapters/tools/router.py:489–502),否则是 None。按 httpx 的 API 约定,不传时用的是 USE_CLIENT_DEFAULT 哨兵,显式传 None 等于 Timeout(None),也就是「No timeouts」(httpx/_client.py:352,370–374、httpx/_config.py:72–84,读的是 server/.venv 里的库源码)。也就是说:没声明超时的注册工具,会绕过客户端那层 30 秒超时,只剩网关那层 30 秒兜底。我没有构造这个调用去实测,所以标成推论。

⑧ Idempotency-Key 只在 POST/PUT/PATCH/DELETE 上设置。 这多半是对的,但「我们把幂等键传给下游了」这句话需要带上这个限定。

⑨ 🔍 推论,不是观测:账本上有两个唯一约束,而 claim() 捕获 IntegrityError 之后只按 run_id + tool_call_id 回查(tool_calls.py:384–389)。如果两个不同的 run 传了同一个显式幂等键,撞的是第二个约束,回查会落空,IntegrityError 会原样抛出去而不是变成 ConflictError。默认键带 run_id,撞不了,所以这条我没能构造出来。

⑩ agent 路径不传 retry_failed,工作流路径传 True。 同一次失败的工具调用,在两条执行路径上语义不同:一个永远回放失败,一个会真的重发。这很可能是有意的(agent 循环可以自己决定换个工具),但没有写在任何文档里。打算提一个 issue。

⑪ MCP 适配器的 401 重试对上层不可见。 下游看到两次请求,账本 attempt_count 仍是 1。影响:按账本做调用量核对时会对不上。绕法:那次重试只发生在鉴权挑战上,业务副作用通常发生在鉴权之后,所以风险有限。

⑫ 限流扣一次、计费记一次,但(在那条目前到不了的路径上)可能真打了两次下游。 平台在图像生成那一处已经识别并处理了同型问题(llm_image_max_retries: int = 0),工具这一处没有对应说明。影响:目前为零,因为那条路径到不了;但一旦有人给工具端口传了一个不带 trace_writer 的装配,它就会回来。

坦白局

  • 本篇没有实跑。 一条命令都没跑,全部结论来自读 soit/ 仓库提交 abf3dc3 上的源码与测试,以及 server/.venv 里 httpx 的库源码。我没有起一套环境去验证「崩溃之后那条记录真的变成 in_doubt」,尽管代码路径是清楚的。第十五节 ⑦ 与 ⑨ 更是明确标注为推论。
  • 我只读了社区版。 Enterprise / Cloud 版本如果有别的重试装配,不在这篇范围里。
  • 「产品里到不了」是对当前装配的判断,不是对所有可能调用方的判断。 依据是「造这个网关的地方只有一处,它的四个调用点都传了 trace_writer」;如果谁在自己的代码里直接 new 一个不带 trace_writer 的网关,那条分支立刻复活。
  • 第十五节里的十二条,没有一条是安全事件。 它们是命名、文档和分层语义对不上,不是「有人被入侵」。
  • 这篇的起点是别人的评论。 那条论据是 reidmarlow 补的,不是我想到的;我做的只是回去核对,然后发现方向反了。
  • 利益相关:我是 SOIT 的维护者。

一句话结论

「重试几次」和「幂等键在不在」是两件事,用一个三目运算符把它们绑在一起,读代码的人会读反;而真正让副作用不重复的,从来不是那个计数器,是一张记着「这次请求到底出没出门」的表。 我们的答案恰好是 1——但它是被三个互相独立的原因叠出来的 1,其中只有一个是有意设计的。

来试试,也来挑刺

仓库在 github.com/soit-ai/soit。这篇里的每一条你都能自己核:

  1. 那行三目运算符在 server/app/kernel/ports/tools/policy.py:349–361,连着上面两行注释一起读;
  2. 语义那件事跑一下我们自己的测试就知道:server/tests/unit/test_port_policy_common.py 里那个断言写着 max_retries=2 对应 attempts == 2;
  3. 第九节那套租约状态机在 server/app/kernel/runtime/runs/tool_calls.py,搜 outbound_started_at 一路读下去,in_doubt 那一支是整篇的支点。

如果你发现我哪一条说错了——尤其是第十五节那十二条里,有没有哪条是我读漏了实现—— 欢迎直接开 issue 打脸。比起被夸「设计得挺清楚」,我更需要知道哪里对不上。

原创声明:本文系作者授权腾讯云开发者社区发表,未经许可,不得转载。

如有侵权,请联系 cloudcommunity@tencent.com 删除。

目录
  • 先看结论
  • 一、这篇的起点是一条读者评论
  • 二、第一层:网关里的那行三目运算符
  • 三、max_retries 数的是尝试次数,不是重试次数
  • 四、于是产品里的答案是 1
  • 五、就算那个 2 能到,也几乎触发不了
  • 六、超时是整段的预算,不是每次尝试的
  • 七、失败离开网关时,已经不是原来的异常了
  • 八、第二层:账本。tool_call_id 是身份,idempotency_key 是断言
  • 九、at-most-once 是怎么落到磁盘上的
  • 十、重放与重跑之间只差一个布尔
  • 十一、第三层:工作流节点,以及同一个名字的三种语义
  • 十二、下游看到几次、账本记了几次、账单数了几次
  • 十三、另一条边界的默认值是相反的
  • 十四、那么那条评论说的事,到底成不成立
  • 十五、现在还对不上的十二个地方
  • 坦白局
  • 一句话结论
  • 来试试,也来挑刺
问题归档专栏文章快讯文章归档关键词归档开发者手册归档开发者手册 Section 归档