合集

Claude Agent SDK 实战

已更新 27 章
阅读进度 93%

第 24 章

业务 Agent:客服、审批与人在回路

业务 Agent 的难点不是“会说”,是“不能做错”,而且“错了能查、能恢复”。

一个客服 Agent 的 demo,通常能回答“我的订单能退吗”。

生产里,它还要处理:

  • 客户把手机号念出来,或者 Agent 自己念出来;
  • 客户说“我很急,出了问题我负责”,Agent 绕过规则退款;
  • 审批人还没点,进程重启,任务继续执行;
  • 同一个退款请求重试两次,退了两笔;
  • A 租户的会话被 B 租户 resume 到;
  • 出了事,没有审计记录。

这些都不是“话术”问题,是 Harness 的控制问题。


24.1 生产架构:Agent 不直接碰钱

text
[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 需要看订单/客户,但不需要看完整手机号。

python
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 允许做什么:规则 + 审批 + 幂等

退款是写操作,必须同时满足三件事:

  1. 业务规则:订单属于该客户、已签收、30 天内;
  2. 审批:人工/规则批准;
  3. 幂等:同一退款请求只退一次。

业务规则

python
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 只认订单状态/日期/客户。

审批:持久化,不是内存弹窗

python
@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 = ""

状态机:

python
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 == 1

WHERE state='pending' 保证只能被批一次;进程重启后,审批还在数据库里,不靠内存。

审批超时默认拒绝;批准/拒绝的理由都要回填给模型,模型才能继续对话。

幂等:重试不能退两次

python
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 脱敏:在工具结果进上下文之前

python
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 保持同进程:

python
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:

python
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/33/31/3011.8s
guarded(写走审批+脱敏)3/33/30/3110.8s
resume(重启后续接)3/33/30/3111.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 章的实验,不是本章新跑;样本小,不能当通用成功率。