幂等键:把重复请求认作同一次操作
正在读取学习记录…
1. 幂等与操作键
幂等描述重复执行时的业务效果:同一次添加标签请求重发,标签仍然只创建一次。幂等键是调用方为一次业务操作生成的标识,服务端用它保存输入和结果。
像取号办事:补打一张凭条,仍然办理原来那一次;重新取号,则进入新的业务。请求编号配对一次通信,幂等键关联一项业务。一次业务可能包含初次发送和几次重试。
服务端先查键,有旧记录且输入相同,返回保存结果;输入不同,返回冲突;没有记录,执行业务并保存。身份、资源和操作内容一起参与比较。每次请求仍经过参数与权限检查。
2. 两次请求,一次创建
在同一新目录建立下面两个文件。
tool_gate.py
先在 PowerShell 创建本篇的独立目录并进入;路径可换成你自己的非系统盘位置:
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 的解释器路径。
"""用内存笔记观察参数校验、资源归属和重复写入。"""
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
"""重复提交同一次添加标签请求,观察返回值与创建次数。"""
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)python tag_demo.py输出:
{"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,例如把循环改成下面内容,再运行:
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
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()python -m unittest test_tool_gate -v测试同时检查参数、归属、输出和重复操作。先恢复刚才的键修改,再运行。查看失败项的名称和期望值,可以定位是哪一层发生变化。
5. 并发、持久化与有效期
当前程序使用一个内存对象顺序执行。真实服务可以把操作键放入数据库并设置唯一约束,把业务变化与操作记录放在同一事务中。这样两个相同请求同时到达时,也能协调创建次数。
键需要明确范围:多租户系统可以把租户、用户、工具与操作键组成唯一身份,避免其他用户碰巧使用相同字符串。记录还需要保留期限;到期后重试可能被识别为新操作,因此期限要覆盖业务允许的重试窗口。
服务崩溃时,内存记录会消失。数据库记录能帮助恢复“已完成但响应丢失”的请求。若上游支付或发信服务也支持幂等接口,可传递稳定的业务编号;若结果暂时未知,先查询回执。
AWS幂等API实践进一步讨论结果回放和调用者意图。总结:同一次业务保留键,同键同内容回放,同键换内容冲突,计数观察真正效果。继续读 超时、重试与补偿。
课后练习展开习题收起习题
正在读取本地记录…
不调用模型,不执行代码。简答、代码和场景题仅保存答案并展示自查标准,不生成评分、徽章或认证。输入内容不会自动保存,请点击提交;不要填写个人信息或密钥。
选择题 · 先判断边界
关于“幂等键与副作用控制”,哪种做法更稳妥?
对应知识点:L2-idempotency
简答题 · 解释失败
写出一个失败输入并说明记录字段。
对应知识点:L2-idempotency
代码题 · 边界测试
补充正常、边界、失败三组输入和预期结果。
对应知识点:L2-idempotency
场景题 · 结果未知
证据不足时如何设计状态和人工接管?
对应知识点:L2-idempotency
描述会发送给本站维护者,用于修订文章,保存90天。请只填写文章问题,省略个人信息、密钥和内部资料。