AgentDepthAgent 工程学习指南
输入概念或问题,检索正文和待编写目录。
第一次学习?从 L0 总览开始 →

幂等键:把重复请求认作同一次操作

我的学习 →

正在读取学习记录…

1. 幂等与操作键

幂等描述重复执行时的业务效果:同一次添加标签请求重发,标签仍然只创建一次。幂等键是调用方为一次业务操作生成的标识,服务端用它保存输入和结果。

像取号办事:补打一张凭条,仍然办理原来那一次;重新取号,则进入新的业务。请求编号配对一次通信,幂等键关联一项业务。一次业务可能包含初次发送和几次重试。

op1第一次创建tag1,同键同内容回放tag1,同键换内容返回冲突
操作键连接输入和结果。重复请求回放原结果,创建计数保持1。

服务端先查键,有旧记录且输入相同,返回保存结果;输入不同,返回冲突;没有记录,执行业务并保存。身份、资源和操作内容一起参与比较。每次请求仍经过参数与权限检查。

2. 两次请求,一次创建

在同一新目录建立下面两个文件。

tool_gate.py

先在 PowerShell 创建本篇的独立目录并进入;路径可换成你自己的非系统盘位置:

Bash
mkdir -Force "F:\学习\agentdepth-l2-idempotency"
cd "F:\学习\agentdepth-l2-idempotency"

将本篇的 tool_gate.py、tag_demo.py、test_tool_gate.py 保存到这个目录。保存全部文件后运行 dir *.py,核对上述文件名,再执行下文命令;新开窗口时先回到该目录,并按 Python 环境篇核对 python 的解释器路径。

Python
"""用内存笔记观察参数校验、资源归属和重复写入。"""

from copy import deepcopy


class ToolError(Exception):
    def __init__(self, code, message):
        super().__init__(message)
        self.code = code


class NoteStore:
    def __init__(self):
        # 两位用户各有一篇笔记,owner 保存归属。
        self.notes = {
            "n1": {"owner": "u1", "text": "仅供 u1 读取的示例笔记"},
            "n2": {"owner": "u2", "text": "u2 的示例笔记"},
        }
        # reads 记录成功读取次数,effects 记录实际创建次数。
        self.reads = 0
        self.effects = 0
        self.seen_keys = {}

    def read(self, identity, note_id):
        note = self.notes.get(note_id)
        if note is None or note["owner"] != identity:
            raise ToolError("forbidden", "resource_not_owned")
        self.reads += 1
        return {"note_id": note_id, "text": note["text"]}

    def create_tag(self, identity, note_id, tag, idem_key):
        if identity != "u1" or note_id != "n1":
            raise ToolError("forbidden", "resource_not_owned")
        # 同一个业务键对应同一次创建动作,保存输入和结果用于去重。
        payload = {"identity": identity, "note_id": note_id, "tag": tag}
        if idem_key in self.seen_keys:
            if self.seen_keys[idem_key]["payload"] != payload:
                raise ToolError("idempotency_conflict", "same_key_different_payload")
            return deepcopy(self.seen_keys[idem_key]["result"])
        self.effects += 1
        result = {"tag_id": "tag-%d" % self.effects, "status": "created"}
        self.seen_keys[idem_key] = {"payload": deepcopy(payload), "result": result}
        return deepcopy(result)


def call_tool(request, identity, store):
    """依次检查工具名、输入字段、资源归属及返回值。"""
    if not isinstance(request, dict):
        raise ToolError("invalid_request", "object_required")
    if request.get("name") not in ("read_note", "create_tag"):
        raise ToolError("unknown_tool", "tool_not_allowlisted")
    args = request.get("arguments")
    if not isinstance(args, dict):
        raise ToolError("invalid_arguments", "object_required")
    if request["name"] == "read_note":
        if set(args) != {"note_id"} or not isinstance(args["note_id"], str) or not args["note_id"].strip():
            raise ToolError("invalid_arguments", "read_note_schema")
        # 先核对笔记归属,再执行读取;读取成功后计数增加。
        note = store.notes.get(args["note_id"])
        if note is None or note["owner"] != identity:
            raise ToolError("forbidden", "resource_not_owned")
        result = store.read(identity, args["note_id"])
        if (not isinstance(result, dict) or set(result) != {"note_id", "text"}
                or not isinstance(result["note_id"], str) or not result["note_id"]
                or not isinstance(result["text"], str)):
            raise ToolError("invalid_tool_result", "read_note_output_schema")
        return result
    if set(args) != {"note_id", "tag", "idempotency_key"}:
        raise ToolError("invalid_arguments", "create_tag_schema")
    if not all(isinstance(args[k], str) and args[k].strip() for k in args):
        raise ToolError("invalid_arguments", "non_empty_strings_required")
    return store.create_tag(identity, args["note_id"], args["tag"], args["idempotency_key"])


def demo():
    store = NoteStore()
    cases = [
        ("own_read", {"name": "read_note", "arguments": {"note_id": "n1"}}, "u1"),
        ("cross_user_read", {"name": "read_note", "arguments": {"note_id": "n1"}}, "u2"),
        ("unknown_tool", {"name": "delete_all", "arguments": {}}, "u1"),
    ]
    # 收集每个请求的结果,最后查看实际读取和创建的次数。
    result = []
    for label, request, identity in cases:
        try:
            value = call_tool(request, identity, store)
            result.append({"case": label, "status": "ok", "value": value})
        except ToolError as error:
            result.append({"case": label, "status": "error", "code": error.code})
    return {"cases": result, "body_reads": store.reads, "effects": store.effects}


if __name__ == "__main__":
    print(demo())

tag_demo.py

Python
"""重复提交同一次添加标签请求,观察返回值与创建次数。"""
from tool_gate import NoteStore, ToolError, call_tool
import json

store = NoteStore()
request = {"name": "create_tag", "arguments": {
    "note_id": "n1", "tag": "todo", "idempotency_key": "op-1"
}}
# 第一次创建,第二次取得同一次操作保存的结果。
for _ in range(2):
    print(json.dumps(call_tool(request, "u1", store)))
print("effects", store.effects)
# 同一个操作键换了内容,会返回冲突。
request["arguments"]["tag"] = "urgent"
try:
    call_tool(request, "u1", store)
except ToolError as error:
    print("conflict", error.code)
Bash
python tag_demo.py

输出:

TXT
{"tag_id": "tag-1", "status": "created"}
{"tag_id": "tag-1", "status": "created"}
effects 1
conflict idempotency_conflict

两次返回都是tag-1,实际创建次数是1。随后同键把标签改为urgent,返回冲突。seen_keys 保存键对应的输入和结果;payload 保存身份、笔记与标签;deepcopy 让返回值与已保存结果分开,避免调用方修改返回字典后影响历史记录。

3. 换一个键会怎样

在循环里给第二次调用设置op-2,例如把循环改成下面内容,再运行:

Python
for key in ("op-1", "op-2"):
    request["arguments"]["idempotency_key"] = key
    print(json.dumps(call_tool(request, "u1", store)))

创建计数变为2。因为op-2代表新的业务操作。恢复原循环后,计数回到1。

操作键输入内容处理效果
新键任意合法内容执行并保存新增一次
旧键与旧输入相同回放结果保持原效果
旧键与旧输入不同冲突保持原效果
新键与以前相同作为新操作执行可能新增一次

生成键之后,应保留到结果确认。每次网络重发都换键,会让服务端按不同业务处理。用户确实要新增第二个标签时,再生成新键。

4. 检查正常与冲突路径

同目录创建test_tool_gate.py:

下面的测试检查同一操作编号重复提交时是否只创建一次标签,以及参数变化时是否拒绝复用旧编号。

测试代码:test_tool_gate.py
Python
import unittest

from tool_gate import NoteStore, ToolError, call_tool


class ToolGateTests(unittest.TestCase):
    # 每个测试都使用一份新笔记库,让读取次数从零开始。
    def setUp(self):
        self.store = NoteStore()

    def test_allowlisted_owned_read(self):
        self.assertEqual(call_tool({"name": "read_note", "arguments": {"note_id": "n1"}}, "u1", self.store)["note_id"], "n1")
        self.assertEqual(self.store.reads, 1)

    def test_cross_user_read_is_rejected_before_read(self):
        with self.assertRaises(ToolError) as error:
            call_tool({"name": "read_note", "arguments": {"note_id": "n1"}}, "u2", self.store)
        self.assertEqual(error.exception.code, "forbidden")
        self.assertEqual(self.store.reads, 0)

    def test_unknown_tool_is_rejected(self):
        with self.assertRaises(ToolError) as error:
            call_tool({"name": "delete_all", "arguments": {}}, "u1", self.store)
        self.assertEqual(error.exception.code, "unknown_tool")

    def test_schema_rejects_extra_field(self):
        with self.assertRaises(ToolError) as error:
            call_tool({"name": "read_note", "arguments": {"note_id": "n1", "admin": True}}, "u1", self.store)
        self.assertEqual(error.exception.code, "invalid_arguments")
        self.assertEqual(self.store.reads, 0)

    def test_schema_rejects_missing_field(self):
        with self.assertRaises(ToolError) as error:
            call_tool({"name": "read_note", "arguments": {}}, "u1", self.store)
        self.assertEqual(error.exception.code, "invalid_arguments")
        self.assertEqual(self.store.reads, 0)

    def test_schema_rejects_wrong_type(self):
        with self.assertRaises(ToolError) as error:
            call_tool({"name": "read_note", "arguments": {"note_id": 3}}, "u1", self.store)
        self.assertEqual(error.exception.code, "invalid_arguments")
        self.assertEqual(self.store.reads, 0)

    def test_blank_note_id_is_rejected_as_arguments(self):
        # 空格字符串在业务含义上仍然缺少编号。
        with self.assertRaises(ToolError) as error:
            call_tool({"name": "read_note", "arguments": {"note_id": "   "}}, "u1", self.store)
        self.assertEqual(error.exception.code, "invalid_arguments")
        self.assertEqual(self.store.reads, 0)

    def test_output_contract_rejects_bad_tool_result(self):
        # 故意让工具返回数字正文,观察输出校验。
        class BrokenStore(NoteStore):
            def read(self, identity, note_id):
                return {"note_id": note_id, "text": 3}

        with self.assertRaises(ToolError) as error:
            call_tool({"name": "read_note", "arguments": {"note_id": "n1"}}, "u1", BrokenStore())
        self.assertEqual(error.exception.code, "invalid_tool_result")

    def test_idempotency_prevents_duplicate_effect(self):
        request = {"name": "create_tag", "arguments": {"note_id": "n1", "tag": "todo", "idempotency_key": "op-1"}}
        # 连续提交相同创建请求,应该得到同一个结果。
        first = call_tool(request, "u1", self.store)
        second = call_tool(request, "u1", self.store)
        self.assertEqual(first, second)
        self.assertEqual(self.store.effects, 1)

    def test_output_note_id_is_nonempty(self):
        # 输出编号也要满足声明的最短长度。
        class EmptyIdStore(NoteStore):
            def read(self, identity, note_id):
                return {"note_id": "", "text": "正文"}

        with self.assertRaises(ToolError) as error:
            call_tool({"name": "read_note", "arguments": {"note_id": "n1"}}, "u1", EmptyIdStore())
        self.assertEqual(error.exception.code, "invalid_tool_result")

    def test_idempotency_conflict_is_rejected(self):
        request = {"name": "create_tag", "arguments": {"note_id": "n1", "tag": "todo", "idempotency_key": "op-1"}}
        call_tool(request, "u1", self.store)
        request["arguments"]["tag"] = "urgent"
        with self.assertRaises(ToolError) as error:
            call_tool(request, "u1", self.store)
        self.assertEqual(error.exception.code, "idempotency_conflict")


if __name__ == "__main__":
    unittest.main()
Bash
python -m unittest test_tool_gate -v

测试同时检查参数、归属、输出和重复操作。先恢复刚才的键修改,再运行。查看失败项的名称和期望值,可以定位是哪一层发生变化。

5. 并发、持久化与有效期

当前程序使用一个内存对象顺序执行。真实服务可以把操作键放入数据库并设置唯一约束,把业务变化与操作记录放在同一事务中。这样两个相同请求同时到达时,也能协调创建次数。

键需要明确范围:多租户系统可以把租户、用户、工具与操作键组成唯一身份,避免其他用户碰巧使用相同字符串。记录还需要保留期限;到期后重试可能被识别为新操作,因此期限要覆盖业务允许的重试窗口。

服务崩溃时,内存记录会消失。数据库记录能帮助恢复“已完成但响应丢失”的请求。若上游支付或发信服务也支持幂等接口,可传递稳定的业务编号;若结果暂时未知,先查询回执。

AWS幂等API实践进一步讨论结果回放和调用者意图。总结:同一次业务保留键,同键同内容回放,同键换内容冲突,计数观察真正效果。继续读 超时、重试与补偿。

课后练习展开习题收起习题

4 道练习 · 客观题规则评分 · 其余题目待批改

正在读取本地记录…

不调用模型,不执行代码。简答、代码和场景题仅保存答案并展示自查标准,不生成评分、徽章或认证。输入内容不会自动保存,请点击提交;不要填写个人信息或密钥。

练习 1 / 4 · v1.0.1

选择题 · 先判断边界

关于“幂等键与副作用控制”,哪种做法更稳妥?

对应知识点:L2-idempotency

选择一个答案
练习 2 / 4 · v1.0.0

简答题 · 解释失败

写出一个失败输入并说明记录字段。

对应知识点:L2-idempotency

练习 3 / 4 · v1.0.0

代码题 · 边界测试

补充正常、边界、失败三组输入和预期结果。

对应知识点:L2-idempotency

练习 4 / 4 · v1.0.0

场景题 · 结果未知

证据不足时如何设计状态和人工接管?

对应知识点:L2-idempotency

图片查看器
100%

文章配图 · 按 Esc 关闭