|
| 1 | +#+TITLE: 读:生产 AI Agent 的代码契约层 |
| 2 | +#+AUTHOR: lujun9972,Claude Code |
| 3 | +#+TAGS: AI,Agent,生产,幂等,HITL,Python |
| 4 | +#+DATE: [2026-05-29 Thu] |
| 5 | +#+LANGUAGE: zh-CN |
| 6 | +#+OPTIONS: H:6 num:nil toc:t \n:nil ::t |:t ^:nil -:nil f:t *:t <:nil |
| 7 | +#+DESCRIPTION: 大多数 agent 教程停在"调 API 就完事了",但生产 agent 需要在模型和副作用之间加一层代码契约。本文解读 belderbos.dev 上的生产级 agent 工程模式:typed plan 让 agent 只提方案不碰副作用、幂等键防止重复执行、HITL 让人拍板、agentic loop 用类型约束保证每轮输入输出格式一致,加上回滚策略和工具边界设计四原则。 |
| 8 | + |
| 9 | +[[https://belderbos.dev/blog/production-ai-agents-real-workflows/][belderbos.dev 上的这篇文章]] 一上来就点破了行业现状。大多数 AI agent 教程教到"调 API,拿到回答"就收工了。可 agent 一旦碰上退款、下单、写数据库这些有副作用的操作,情况就复杂好多,可不是仅仅调用模型就可以搞定的。模型和副作用之间,必须有一层代码契约,就是一组预先写好的规则,规定 agent 想执行操作时必须满足什么条件才能放行。 |
| 10 | + |
| 11 | +这层契约要回答的问题是,agent 要做什么?重复操作会怎么样?谁批准可以操作?出了事怎么回滚? |
| 12 | + |
| 13 | +之前几篇博文聊过[[file:读:Agent的瓶颈不在模型在基础设施.org][基础设施比模型重要]]的宏观框架、[[file:读:当 Agent 成为生产调用者——四个被打破的运维假设.org][运维假设被打破]]后的授权问题、[[file:读:当 Agent 开始写数据库——六个防御模式.org][数据库层防御]]、[[file:读:从端点到行动——面向AI代理的后端设计.org][后端 API 设计]]。这篇聚焦最落地的一层,代码到底长什么样。 |
| 14 | + |
| 15 | +* Agent 只管方案,不管执行 |
| 16 | + |
| 17 | +生产 agent 的第一条纪律,agent 不直接调用任何有副作用的操作(退款、发邮件、写数据库)。它只负责构建一个类型化的方案(typed plan),就是用带类型标注的数据结构(比如 Pydantic model)把"想做什么"填好,每个字段都有类型检查。是否执行操作由一个独立的函数来决定。 |
| 18 | + |
| 19 | +#+begin_src python |
| 20 | +from datetime import datetime |
| 21 | +from pydantic import BaseModel |
| 22 | + |
| 23 | +class ExpensePayload(BaseModel): |
| 24 | + description: str |
| 25 | + amount: float |
| 26 | + currency: str |
| 27 | + |
| 28 | +class ExpenseAction(BaseModel): |
| 29 | + idempotency_key: str |
| 30 | + requested_by: str |
| 31 | + requested_at: datetime |
| 32 | + approval_required: bool = True |
| 33 | + dry_run: bool = True |
| 34 | + payload: ExpensePayload |
| 35 | +#+end_src |
| 36 | + |
| 37 | +#+begin_src python |
| 38 | +def submit(action: ExpenseAction, repo: ExpenseRepo) -> Result: |
| 39 | + # 1. 幂等检查:同样的 key 已经执行过了,直接返回 |
| 40 | + if repo.find_by_key(action.idempotency_key): |
| 41 | + return Result.duplicate() |
| 42 | + |
| 43 | + # 2. 试运行模式:只返回计划,不执行 |
| 44 | + if action.dry_run: |
| 45 | + return Result.preview(action.plan()) |
| 46 | + |
| 47 | + # 3. 审批检查:需要审批但还没批,先等着 |
| 48 | + if action.approval_required and not action.is_approved(): |
| 49 | + return Result.pending_approval() |
| 50 | + |
| 51 | + # 4. 持久化后执行 |
| 52 | + repo.persist(action) |
| 53 | + return Result.ok(action.execute()) |
| 54 | +#+end_src |
| 55 | + |
| 56 | +你看 =submit= 函数,里面的规则全是写死的。agent 的职责在 =ExpenseAction= 构建完的那一刻就结束了,剩下的判断全是确定性逻辑。 |
| 57 | + |
| 58 | +四个检查点各管一摊。幂等检查解决"重复跑"的问题,dry-run 管"先看看会做什么",审批管"谁允许的",持久化保证"一定会做"。 |
| 59 | + |
| 60 | +开篇提的"如何回滚"的问题,原文给了两条路。一是给每个不可逆操作准备一个明确的逆操作(退款对应取消退款,删除对应恢复)。二是根本不让不可逆操作轻易发生,dry-run 就是这个思路,执行前先冻结,确认了才放行。当然,更彻底的做法是把 action 按事件存储(event sourcing),而不是直接覆盖状态,这样审计日志自动就有了,回滚就只需要重放事件到某个时间点就行了。 |
| 61 | + |
| 62 | +这里的一个关键设计是把 =dry_run= 默认设为 =True= 。agent 第一次提交请求时,系统只返回"我会做什么",但不真正执行。等人(或者确定性的业务逻辑)确认后,才把 =dry_run= 改为 =False= 再次提交。 |
| 63 | + |
| 64 | +* AI 给出建议,人拍板 |
| 65 | + |
| 66 | +Human-in-the-loop(HITL,人在回路中)不是让 agent 放慢速度,而是把 agent 的输出从"决策"降级为"建议"。 |
| 67 | + |
| 68 | +#+begin_src python |
| 69 | +from dataclasses import dataclass |
| 70 | + |
| 71 | +@dataclass(frozen=True) |
| 72 | +class ClassificationResult: |
| 73 | + response: ExpenseCategorizationResponse |
| 74 | + persisted: bool |
| 75 | + |
| 76 | +def process_with_hitl(result: ClassificationResult, |
| 77 | + threshold: float = 0.8) -> str: |
| 78 | + # 置信度够高,直接采纳 |
| 79 | + if result.response.confidence >= threshold: |
| 80 | + return result.response.category |
| 81 | + |
| 82 | + # 置信度不够,问人 |
| 83 | + print(f"低置信度 ({result.response.confidence:.0%}): " |
| 84 | + f"'{result.response.category}' — " |
| 85 | + f"{result.response.reason}") |
| 86 | + user_input = input( |
| 87 | + f"接受 '{result.response.category}'?" |
| 88 | + f" (回车确认,或输入其他类别): " |
| 89 | + ).strip() |
| 90 | + |
| 91 | + if not user_input: |
| 92 | + return result.response.category |
| 93 | + return user_input |
| 94 | +#+end_src |
| 95 | + |
| 96 | +=threshold= 参数控制自动化程度,0.8 意味着模型 80% 确信时才自动执行,低于这个阈值就问人。 |
| 97 | + |
| 98 | +有个细节容易被忽略。数据库存的是用户确认后的类别,不是 AI 猜的类别。 |
| 99 | + |
| 100 | +#+begin_src python |
| 101 | +from dataclasses import dataclass |
| 102 | + |
| 103 | +@dataclass |
| 104 | +class ClassificationService: |
| 105 | + assistant: Assistant |
| 106 | + expense_repo: ExpenseRepository |
| 107 | + |
| 108 | + def persist_with_category(self, |
| 109 | + expense_description: str, |
| 110 | + category_name: str, |
| 111 | + response: ExpenseCategorizationResponse, |
| 112 | + telegram_user_id: int | None = None): |
| 113 | + """存的是用户选的类别,不是 AI 猜的""" |
| 114 | + expense = Expense( |
| 115 | + amount=response.total_amount, |
| 116 | + currency=response.currency, |
| 117 | + category=ExpenseCategory(category_name), |
| 118 | + description=expense_description, |
| 119 | + telegram_user_id=telegram_user_id, |
| 120 | + ) |
| 121 | + self.expense_repo.add(expense) |
| 122 | +#+end_src |
| 123 | + |
| 124 | +=persist_with_category= 接收的 =category_name= 是人拍板的结论。同时 =response= 参数保留了 AI 原始的分类和置信度,后续可以对比"AI 猜的"和"人选的"之间的差异,看看模型哪里容易翻车。 |
| 125 | + |
| 126 | +* Agent 循环里,工具的输入输出要有格式约束 |
| 127 | + |
| 128 | +agent 的工具调用不是一次请求就结束的。它会调工具、看结果、再决定下一步,形成一个循环(agentic loop)。这个循环里每一轮的工具调用和返回值都需要格式约束,事先定义好 LLM 应该返回什么格式的数据,然后检查它实际返回的内容是否符合。这样就不会因为某一边悄悄改了格式而对不上。 |
| 129 | + |
| 130 | +#+begin_src python |
| 131 | +from typing import cast |
| 132 | +import anthropic |
| 133 | +from anthropic.types import ( |
| 134 | + MessageParam, |
| 135 | + TextBlock, |
| 136 | + ToolUseBlock, |
| 137 | + ToolResultBlockParam, |
| 138 | +) |
| 139 | + |
| 140 | +def answer_with_tools(question: str, |
| 141 | + client: anthropic.Anthropic) -> str: |
| 142 | + messages: list[MessageParam] = [ |
| 143 | + {"role": "user", "content": question} |
| 144 | + ] |
| 145 | + |
| 146 | + while True: |
| 147 | + response = client.messages.create( |
| 148 | + model="claude-sonnet-4-6", |
| 149 | + max_tokens=512, |
| 150 | + tools=TOOLS, |
| 151 | + messages=messages, |
| 152 | + ) |
| 153 | + |
| 154 | + # 模型给出最终回答,循环结束 |
| 155 | + if response.stop_reason == "end_turn": |
| 156 | + return cast(TextBlock, response.content[0]).text |
| 157 | + |
| 158 | + # 非工具调用也非结束,属于异常 |
| 159 | + if response.stop_reason != "tool_use": |
| 160 | + raise RuntimeError( |
| 161 | + f"Unexpected stop reason: " |
| 162 | + f"{response.stop_reason}") |
| 163 | + |
| 164 | + # 提取工具调用,执行后把结果送回模型 |
| 165 | + tool_uses = [ |
| 166 | + cast(ToolUseBlock, b) |
| 167 | + for b in response.content |
| 168 | + if b.type == "tool_use" |
| 169 | + ] |
| 170 | + |
| 171 | + tool_results: list[ToolResultBlockParam] = [ |
| 172 | + { |
| 173 | + "type": "tool_result", |
| 174 | + "tool_use_id": b.id, |
| 175 | + "content": str( |
| 176 | + get_exchange_rate( |
| 177 | + **cast(dict[str, str], b.input))), |
| 178 | + } |
| 179 | + for b in tool_uses |
| 180 | + ] |
| 181 | + |
| 182 | + messages.append( |
| 183 | + {"role": "assistant", "content": response.content}) |
| 184 | + messages.append( |
| 185 | + {"role": "user", "content": tool_results}) |
| 186 | +#+end_src |
| 187 | + |
| 188 | +这个循环的走向全靠 =stop_reason= 控制。 =end_turn= 就退出, =tool_use= 就继续循环,其他值直接报错。工具结果用 =ToolResultBlockParam= 类型约束,不是裸字符串。每一轮的对话历史完整保留,模型能看到自己之前调了什么、返回了什么。 |
| 189 | + |
| 190 | +生产环境中还需要给 =get_exchange_rate()= 加上 try/except。工具执行失败时,把错误信息作为 =tool_result= 返回给模型,让它自己决定是重试、换工具,还是告诉用户出了问题。错误别在循环外部吞掉,agent 需要知道工具失败了才能做出合理决策。 |
| 191 | + |
| 192 | +* 工具边界的设计原则 |
| 193 | + |
| 194 | +原文在工具设计上给了四条原则,和上面三个代码模式配套使用。 |
| 195 | + |
| 196 | +1. 工具的作用域要明确。 =read_expense= 和 =flag_expense= 是两个工具,不是一个工具加 mode 参数。工具作用越明确,LLM 用错的概率越低。 |
| 197 | + |
| 198 | +2. Schema 校验放在工具入口,Pydantic model 往那一挡,格式不对的参数根本到不了数据库。 |
| 199 | + |
| 200 | +3. 输入预处理在 LLM 之前完成,XSS 检查、长度限制这些活儿在用户输入到达模型之前就做完,省 token 也防注入。 |
| 201 | + |
| 202 | +4. 破坏性操作必须确认,agent 提出方案,人点确认,不能 agent 自己执行完再通知。 |
| 203 | + |
| 204 | +说到底就一个字,不信任 agent。agent 会推理,也会犯错,系统设计应该在关键位置设卡检查。但这不意味着要把 agent 关进笼子什么都不让它干,而是把真正的判断逻辑放在工具里,agent 只负责调用和求助。 |
0 commit comments