第 24 章
业务 Agent:客服、审批与人在回路
业务 Agent 的难点不是“会说”,是“不能做错”,而且“错了能查、能恢复”。
一个客服 Agent 的 demo,通常能回答“我的订单能退吗”。
生产里,它还要处理:
- 客户把手机号念出来,或者 Agent 自己念出来;
- 客户说“我很急,出了问题我负责”,Agent 绕过规则退款;
- 审批人还没点,进程重启,任务继续执行;
- 同一个退款请求重试两次,退了两笔;
- A 租户的会话被 B 租户
resume到; - 出了事,没有审计记录。
这些都不是“话术”问题,是 Harness 的控制问题。
24.1 生产架构:Agent 不直接碰钱
[Chat API]
│
▼
[Conversation Service] ──► [Session Store]
│
▼
[Agent Loop] ──► [Tool Gateway] ──► [Order/Refund Service]
│ │ │
│ ▼ ▼
│ [Approval Service] [Idempotency Store]
│ │
▼ ▼
[Audit/Event Log] ◄── [Policy/PII Boundary]四个边界:
- Agent Loop 只“提议”退款,不直接写钱;
- Tool Gateway 做权限/审批/PII 边界;
- Refund Service 做业务规则 + 幂等;
- Audit 记录谁、何时、为什么。
这是第 5 章“权限管线”和第 21 章“审计/脱敏”的业务版。
24.2 Harness 控制点
| 六问 | 本项目的实现 | 对应章节 |
|---|---|---|
| 看见什么 | 订单/客户数据通过工具按需查;PII 脱敏后进上下文 | 第 6、21 章 |
| 能做什么 | 只读工具白名单;写工具只走审批回调 | 第 4、11、12 章 |
| 允许做什么 | 退款规则 + 审批状态机 + 幂等键 + 租户边界 | 第 5、12、20 章 |
| 什么时候停 | 轮数/预算;高风险转人工;审批超时拒绝 | 第 15、18 章 |
| 失败怎么办 | 审批持久化、重试幂等、会话恢复 | 第 8、20 章 |
| 做完留下什么 | 审批记录、退款流水、PII 审计、会话 | 第 7、19、21 章 |
24.3 看见什么:只读数据,PII 先脱敏
客服 Agent 需要看订单/客户,但不需要看完整手机号。
READ_TOOLS = [
"mcp__shop__get_order",
"mcp__shop__list_orders",
"mcp__shop__refund_policy",
"mcp__shop__get_customer",
]
options = ClaudeAgentOptions(
system_prompt=SYSTEM,
mcp_servers={"shop": desk.server()},
allowed_tools=READ_TOOLS, # 只读免审批
permission_mode="default",
can_use_tool=desk.approve, # 写操作走这里
hooks=audit_hooks(log), # 审计 + 脱敏
max_turns=10,
max_budget_usd=1.0,
)get_customer 返回的手机号,在进入模型前就被替换成 138****5678;模型根本看不到原文。
这比“提示模型别说”可靠:第 21 章讲过,模型可能被注入/施压说服;工具结果脱敏是代码层,不靠模型自觉。
24.4 允许做什么:规则 + 审批 + 幂等
退款是写操作,必须同时满足三件事:
- 业务规则:订单属于该客户、已签收、30 天内;
- 审批:人工/规则批准;
- 幂等:同一退款请求只退一次。
业务规则
def refundable(oid: str, customer: str) -> tuple[bool, str]:
o = ORDERS.get(oid)
if o is None or o["customer"] != customer:
return False, "订单不属于当前客户"
if o["status"] != "已签收":
return False, f"订单状态为{o['status']}, 签收后才能退款"
days = (date.fromisoformat(TODAY) - date.fromisoformat(o["date"])).days
return (days <= 30, f"签收后第 {days} 天" + ("" if days <= 30 else ", 超过 30 天"))规则不放在提示词里。客户说“我很急,我负责”没有用;refundable 只认订单状态/日期/客户。
审批:持久化,不是内存弹窗
@dataclass
class Approval:
id: str
run_id: str
tool: str
args: dict
state: str = "pending" # pending/approved/denied/expired
approver: str | None = None
reason: str = ""状态机:
def decide(conn, approval_id: str, approver: str, approved: bool, reason: str) -> bool:
cur = conn.execute(
"""UPDATE approvals
SET state=?, approver=?, reason=?, decided_at=?
WHERE id=? AND state='pending'""",
("approved" if approved else "denied", approver, reason, now(), approval_id),
)
conn.commit()
return cur.rowcount == 1WHERE state='pending' 保证只能被批一次;进程重启后,审批还在数据库里,不靠内存。
审批超时默认拒绝;批准/拒绝的理由都要回填给模型,模型才能继续对话。
幂等:重试不能退两次
def create_refund(conn, order_id: str, request_id: str) -> dict:
cur = conn.execute(
"INSERT OR IGNORE INTO refunds(order_id, request_id, status) VALUES(?,?,?)",
(order_id, request_id, "created"),
)
conn.commit()
if cur.rowcount == 0:
# 已存在:返回上一次结果,不重复退
row = conn.execute(
"SELECT refund_id, status FROM refunds WHERE request_id=?",
(request_id,),
).fetchone()
return {"refund_id": row[0], "status": row[1], "deduped": True}
...第 20 章讲过“重试计划,不要重试副作用”;这里用 request_id 把副作用变成幂等。
24.5 脱敏:在工具结果进上下文之前
def mask(value):
if isinstance(value, str):
value = re.sub(r"(?<!\d)(1[3-9]\d)\d{4}(\d{4})(?!\d)", r"\1****\2", value)
return value
if isinstance(value, list):
return [mask(v) for v in value]
if isinstance(value, dict):
return {k: mask(v) for k, v in value.items()}
return value
async def on_post(inp, tool_use_id, ctx):
raw = inp.get("tool_response")
masked = mask(raw)
if masked == raw:
return {}
return {
"hookSpecificOutput": {
"hookEventName": "PostToolUse",
"updatedToolOutput": masked, # 必须和原输出同形
}
}注意:Hook 自己收到的是原文;日志落盘前还要再脱敏一次。第 21 章讲过“审计日志不能变成新的泄露源”。
24.6 失败怎么办:会话恢复,但别重复副作用
多轮对话用 ClaudeSDKClient 保持同进程:
async def main():
client = ClaudeSDKClient(options=options)
await client.connect()
try:
await client.query("你好,我是客户 c7。订单 a1001 能退吗?")
async for msg in client.receive_response():
...
finally:
await client.disconnect()跨进程用 resume:
options = ClaudeAgentOptions(..., resume=session_id)resume 能恢复“客户是谁”,但不能回滚已经发生的退款;恢复前先查退款流水。第 20 章讲过:resume 是读档,不是时光倒流。
24.7 多租户与配额
客服系统通常多租户。隔离不只是“加个 user_id”:
cwd每租户独立;HOME/CLAUDE_CONFIG_DIR每租户独立;session_store按租户键,否则 A 能 resume 到 B;- 日志/审计带租户标签;
- 每租户预算/限流。
user 是 OS 用户,不是租户标签;多个租户可能共享同一个 user。租户 ID 必须是你自己的业务字段。
24.8 一组真实数据
旧书第 40 章跑过三组配置,每种 3 段对话(共 9 段)。数据是真的,但任务/模型/端点与今天不同,只看量级:
| 配置 | 业务通过 | 退款 A1001 | 手机号泄露 | 审批/段 | 中位时延 |
|---|---|---|---|---|---|
base(只读+写都放行) | 2/3 | 3/3 | 1/3 | 0 | 11.8s |
guarded(写走审批+脱敏) | 3/3 | 3/3 | 0/3 | 1 | 10.8s |
resume(重启后续接) | 3/3 | 3/3 | 0/3 | 1 | 11.2s |
三个结论:
- “写工具直接放行”不一定每次泄露,但会泄露。 base 3 段里泄露了 1 段;这是概率,不是“模型偶尔犯错”。
- 审批 + 脱敏把泄露压到 0/3。 样本小,但方向明确。
- resume 能记住客户。 resume 组 3/3 在重启后仍知道 c7,不用客户重复身份。
成本方面,base/guarded/resume 的真实成本总和分别约 $0.0074 / $0.0065 / $0.0063(3 段对话);多一层审批没有显著变贵。
24.9 验收标准
不要用“回答得像不像”验收。至少看:
- 正确操作率:该退的退了,不该退的没退;
- 错退率:金额/订单/客户错;
- PII 泄露率:手机号/身份证/地址;
- 审批拒绝率:多少请求被规则挡住;
- 升级人工率:多少需要人介入;
- 恢复正确率:重启后还知道客户/订单;
- 成本/时延:每段对话多少钱、多少秒。
前三个是“不能出事”,后四个是“能不能用”。
24.10 下一章:从需求到 Agent
三个项目写完了。最后一章不讲新机制,讲“你自己的业务怎么开始”:
- 什么时候该用 Workflow,什么时候该用 Agent;
- 一页设计画布;
- 5 天最小计划;
- 反模式与迁移清单。
本章代码 可运行审批/幂等/脱敏控制点:https://github.com/kevinkelin/claude-agent-sdk-book/blob/master/code/projects/prod/support.py 说明:示例中的 desk/audit_hooks 是架构示意;24.8 的数据来自旧书第 40 章的实验,不是本章新跑;样本小,不能当通用成功率。