Skip to content

AI 能改页面之后,谁为这次操作负责? ​

前两篇分别处理了“页面怎么看”和“对话进行到哪里”。最后一篇进入真正危险的地方:用户说“删除客户 A”,AI 发起 Tool Call,聊天里还显示了一个绿色的“执行成功”。

客户 A 就真的被删除了吗?

🎯 本文目标

读完你应该能:分清 Tool Call、人工确认和业务事务;设计 proposed → awaiting_approval → executing → succeeded / failed / unknown 状态机;理解为什么权限、幂等、版本校验和审计必须留在服务端;最后看懂手写 action bridge、CopilotKit 与 Tambo 帮你连接了什么,又没有替你负责什么。

本篇故意不讲什么

  • 确认弹窗是否打开、按钮是否 loading 属于 UI 状态。
  • Tool Call 出现在第几条消息、Agent 是否等待工具结果属于对话状态。
  • 本篇只讨论真实业务事实:谁有权限、命令是否只执行一次、数据库是否提交、失败后怎样确认结果。

同一个“删除”,会经过三种不同的成功 ​

用户对 CRM 助手说:

删除客户 A。

系统可能依次出现:

  1. 页面弹出确认卡片;
  2. Agent 生成 deleteCustomer({ customerId: 'A' });
  3. 用户点击确认;
  4. 前端调用删除接口;
  5. 数据库删除记录并写审计;
  6. Tool Result 回填“成功”。

这六步至少包含三种不同事实:

表面现象它真正证明了什么归属
确认卡片显示出来页面正在展示确认 UIUI 状态
Tool Call 参数解析完成Agent 提出了一个动作对话状态
用户点击“确认”用户表达了同意对话输入 / 业务命令前置条件
HTTP 返回 200该请求收到成功响应传输结果,还要看业务语义
数据库事务提交客户记录发生真实变化业务状态
Tool Result 写入消息对话记录了执行结果对话状态

最危险的误区是:

text
Tool Call complete
        ≠
Business transaction committed

Tool Call 只是模型和应用之间的协议事件。它可以参数错误、越权、重复执行,也可能在数据库已经提交后丢失响应。绿色卡片只是一个投影,不能成为最终真相。


业务状态机必须比 loading 更诚实 ​

对有副作用的动作,loading / success / error 不够。最小状态机应该是:

text
proposed
   ↓
awaiting_approval ──拒绝──→ rejected
   ↓ 同意
executing
   ├──→ succeeded
   ├──→ failed
   └──→ unknown ──查询命令状态──→ succeeded / failed

为什么需要 unknown ​

假设服务端已经提交删除事务,但返回响应前网络断了:

  • 浏览器看到 fetch failed;
  • Agent 看不到 Tool Result;
  • 客户实际上已经删除。

此时显示“删除失败”是撒谎,直接重试又可能重复执行。正确状态是 unknown:我不知道响应,但我知道命令可能已经发生,必须用命令 ID 查询。

typescript
type BusinessActionState =
  | { status: 'proposed'; proposalId: string }
  | { status: 'awaiting_approval'; proposalId: string }
  | { status: 'rejected'; proposalId: string }
  | { status: 'executing'; commandId: string }
  | { status: 'succeeded'; commandId: string; result: unknown }
  | { status: 'failed'; commandId: string; errorCode: string }
  | { status: 'unknown'; commandId: string }

cancelled 也需要谨慎使用:请求尚未发出时可以真正取消;事务已经提交后,关闭弹窗或中止聊天不能撤销现实。那时如果需要“恢复”,应该发起一个新的补偿命令,而不是把旧命令改成 cancelled。


Proposal、Command、Result 必须是三个对象 ​

把模型生成的参数直接当命令执行,会丢失审查和重放边界。更稳的模型是:

typescript
type DeleteCustomerProposal = {
  proposalId: string
  type: 'delete-customer'
  customerId: string
  reason?: string
  generatedAt: string
}

type DeleteCustomerCommand = {
  commandId: string
  proposalId: string
  customerId: string
  expectedVersion: number
  approvedBy: string
  approvedAt: string
}

type DeleteCustomerResult = {
  commandId: string
  status: 'succeeded' | 'failed'
  customerId: string
  committedAt?: string
  errorCode?: string
}
  • Proposal:模型建议做什么,可以被编辑、拒绝或过期;
  • Command:用户确认后提交给业务服务的不可歧义命令;
  • Result:服务端经过权限、版本和事务校验后给出的事实。

模型可以生成 Proposal,不应该生成 approvedBy、权限结论或最终 Result。


人工确认不是权限校验 ​

用户点击“确认删除”只能说明他同意,不能证明他有权限。前端传来的这些字段都不可信:

json
{
  "userId": "admin",
  "isAdmin": true,
  "approved": true
}

服务端必须从可信 session / token 重新得到身份,并重新检查:

  1. 当前用户是否能访问这个 tenant;
  2. 是否有删除客户权限;
  3. 客户是否仍存在;
  4. 客户版本是否仍与确认时一致;
  5. 是否有未结清订单等业务约束;
  6. command ID 是否已经执行过。

确认 UI 解决的是“避免模型擅自做决定”,服务端鉴权解决的是“这个人是否允许做决定”。它们不能互相替代。


幂等:用户点两次、Agent 重试两次,也只能执行一次 ​

AI 场景特别容易重复提交:

  • 用户双击确认;
  • 前端超时自动重试;
  • Agent 因没收到 Tool Result 再调一次工具;
  • 消息恢复后重新播放未完成步骤;
  • 网关重放请求。

因此每个业务命令需要稳定的 commandId / idempotency key:

typescript
async function executeDeleteCustomer(command: DeleteCustomerCommand, actor: Actor) {
  return db.transaction(async (tx) => {
    const previous = await tx.commandResult.findUnique({
      where: { commandId: command.commandId },
    })

    // 相同命令重放时返回第一次结果,不再次删除
    if (previous) return previous

    await authorize(actor, 'customer.delete', command.customerId, tx)

    const customer = await tx.customer.findUniqueOrThrow({
      where: { id: command.customerId },
    })

    if (customer.version !== command.expectedVersion) {
      throw new ConflictError('CUSTOMER_CHANGED_AFTER_APPROVAL')
    }

    await assertCustomerCanBeDeleted(customer, tx)

    await tx.customer.delete({ where: { id: customer.id } })

    const result = {
      commandId: command.commandId,
      status: 'succeeded' as const,
      customerId: customer.id,
      committedAt: new Date().toISOString(),
    }

    await tx.commandResult.create({ data: result })
    await tx.auditLog.create({
      data: {
        actorId: actor.id,
        action: 'customer.delete',
        targetId: customer.id,
        commandId: command.commandId,
        proposalId: command.proposalId,
      },
    })

    return result
  })
}

关键不是某个 ORM,而是四件事在同一个可信边界完成:

text
幂等检查 + 权限校验 + 业务写入 + 审计记录

如果“删除成功”提交了,审计却另起一个异步请求并失败,系统就失去可追溯性。高风险业务应让关键审计与事务保持一致,或采用可靠 outbox 等机制。


版本校验:确认的是刚才看到的对象,不是任意未来状态 ​

用户确认时看到:

text
客户 A,版本 12,无未结订单

确认后到执行前,另一个用户可能创建了新订单。如果命令只带 customerId,删除会作用于一个已经变化的对象。

所以 Command 应携带确认时的 expectedVersion,服务端执行时做乐观并发控制:

sql
DELETE FROM customers
WHERE id = :customerId
  AND version = :expectedVersion;

影响行数为 0 时,不应该自动换成最新版本重试,而应回到 failed/conflict,让用户基于新事实重新确认。

人工确认也会过期

审批不是一张永久通行证。参数被修改、对象版本变化、权限变化、确认超时后,都应该产生新 Proposal 或重新审批。


Tool Result 应该回填事实,而不是情绪化文案 ​

不推荐:

json
{ "result": "搞定啦,客户已经删除!" }

更适合对话层记录的是结构化结果:

json
{
  "commandId": "cmd-20260827-001",
  "status": "succeeded",
  "customerId": "customer-A",
  "committedAt": "2026-08-27T10:30:00+08:00"
}

UI 可以把它渲染成人话,Agent 也可以据此继续总结;但数据库和审计仍是业务真相。Tool Result 是真相的对话投影,不是新的真相来源。

失败同样要稳定编码:

json
{
  "commandId": "cmd-20260827-001",
  "status": "failed",
  "errorCode": "CUSTOMER_CHANGED_AFTER_APPROVAL",
  "retryable": false
}

不要把数据库异常堆栈和内部 SQL 直接交给模型或用户;也不要只返回“失败了”,让调用方无法判断是否可以重试。


前端动作和业务副作用不是一类工具 ​

工具可以按风险分级:

工具执行位置是否需要确认最终真相
切换主题浏览器通常不需要前端状态
打开订单详情浏览器通常不需要路由 / UI
筛选当前表格浏览器视情况UI 查询条件
查询订单服务端只读 API通常不需要服务端查询结果
修改订单服务端命令 API通常需要数据库事务
付款、删除、发信服务端命令 / 外部系统强确认与审计业务系统 / 外部回执

“Frontend Tool”描述工具 handler 在浏览器运行,不代表所有业务副作用都适合在浏览器完成。浏览器 handler 可以调用受保护的服务端命令 API,但权限、幂等和事务不能留在 handler 里。


三种连接方式,只比较动作责任 ​

方案一:手写 Action Bridge ​

最小桥接由三部分组成:

typescript
type ActionDefinition<T> = {
  name: string
  description: string
  parse: (input: unknown) => T
  risk: 'ui-only' | 'read' | 'write' | 'critical'
  execute: (input: T, context: ActionContext) => Promise<unknown>
}

执行器按风险决定是否审批:

typescript
async function dispatchAction(name: string, rawInput: unknown) {
  const action = registry[name]
  const input = action.parse(rawInput)

  if (action.risk === 'write' || action.risk === 'critical') {
    return createProposal({ action: name, input })
  }

  return action.execute(input, context)
}

优点: 责任最透明;适合已有权限平台、命令总线和审计体系的公司。

代价: 工具注册、参数流、确认暂停、结果回填、UI 渲染和恢复协议都要自己维护。

方案二:CopilotKit ​

CopilotKit v2 用 useFrontendTool 注册浏览器工具:

bash
npm install @copilotkit/react-core @copilotkit/runtime
tsx
import { useFrontendTool } from '@copilotkit/react-core/v2'
import { z } from 'zod'

useFrontendTool({
  name: 'openCustomer',
  description: 'Open a customer detail panel',
  parameters: z.object({ customerId: z.string() }),
  handler: async ({ customerId }) => {
    navigate(`/customers/${customerId}`)
    return { opened: true, customerId }
  },
})

这种 UI-only 动作非常适合前端工具。对于需要人工输入或审批的工具,v2 的 useHumanInTheLoop 会暂停 Agent,并在 render 中提供 respond:

tsx
useHumanInTheLoop({
  name: 'approveDeleteCustomer',
  description: 'Ask the user to approve deleting a customer',
  parameters: z.object({
    customerId: z.string(),
    expectedVersion: z.number(),
  }),
  render: ({ args, status, respond }) => {
    if (status !== ToolCallStatus.Executing) return <ActionStatus />

    return (
      <ConfirmDelete
        customerId={args.customerId}
        onReject={() => respond?.({ approved: false })}
        onApprove={async () => {
          const result = await callDeleteCommandApi(args)
          respond?.({ approved: true, result })
        }}
      />
    )
  },
})

它替你连接了:工具注册、参数 schema、Agent 暂停/恢复和 Tool UI 生命周期。

它没有替你解决:服务端鉴权、command ID、事务、版本冲突、审计和未知结果查询。把 fetch('/api/delete') 放进 respond 前,后端仍要完成上一节的全部护栏。

available: enabled 不是权限系统

前端可以按当前界面隐藏或禁用工具,减少模型误调用;攻击者仍可绕过页面直接调用业务 API。真正授权必须在服务端执行时重新判断。

方案三:Tambo ​

Tambo 面向生成式 React UI,通过 provider 注册 components 和 tools:

bash
npx tambo full-send

# 或手动安装 SDK
npm install @tambo-ai/react
typescript
const tools: TamboTool[] = [
  {
    name: 'getCustomer',
    description: 'Read a customer by id',
    inputSchema: z.object({ customerId: z.string() }),
    outputSchema: CustomerSchema,
    tool: ({ customerId }) => api.getCustomer(customerId),
    annotations: {
      readOnlyHint: true,
      tamboStreamableHint: true,
    },
  },
]

Tambo 的组件注册、工具关联和 useTamboComponentState 适合“AI 生成一个可继续交互的业务组件”。组件 state 可以被 AI 看见并随 thread 恢复,这很方便,但仍要区分:

  • 表单里尚未提交的 emailBody 是交互状态;
  • “邮件已发送”必须来自邮件服务回执;
  • “付款已完成”必须来自支付系统状态。

官方类型中特别注明 tamboStreamableHint 适合无副作用的只读工具。原因很直接:参数还在流式生成时就执行写操作,可能拿到半截或后来被修改的参数。写工具必须等参数完整、校验通过,并经过需要的确认。

适合: 业务体验核心是生成式组件和可持续交互状态。

边界: Tambo 帮助模型选择、渲染和更新组件,不会替业务服务承担事务真相。


一张责任表看清三种连接方式 ​

能力手写 bridgeCopilotKitTambo
工具注册与 schema自己实现useFrontendTool / ZodTamboTool / schema
Tool UI自己实现render hooks / component注册生成式组件
人工暂停与恢复自己设计协议useHumanInTheLoop结合组件交互自行设计业务审批
UI-only 动作自己调度很适合可通过 tools/components 完成
组件状态给 AI 可见自己同步通过 Agent 上下文/工具设计useTamboComponentState
服务端鉴权自己负责自己负责自己负责
幂等与事务自己负责自己负责自己负责
版本冲突自己负责自己负责自己负责
审计与未知结果恢复自己负责自己负责自己负责

框架之间真正的差异是“动作怎样进入前端、怎样暂停对话、怎样呈现”;业务状态最后四行始终属于你的业务服务。


失败恢复必须覆盖四种坏路径 ​

1. 权限失败 ​

服务端返回稳定错误码,Tool Result 记录 PERMISSION_DENIED;UI 不要给一个无效的“重试”按钮。

2. 对象已变化 ​

返回 VERSION_CONFLICT,重新查询对象并要求用户基于新状态确认,不能偷偷升级 expectedVersion。

3. 事务失败 ​

返回明确 failed;确认 command result 表中没有成功记录后,才允许使用同一个 command ID 重放。

4. 响应丢失 ​

进入 unknown,通过独立查询接口确认:

http
GET /api/commands/cmd-20260827-001
json
{
  "commandId": "cmd-20260827-001",
  "status": "succeeded",
  "result": {
    "customerId": "customer-A"
  }
}

不要生成一个新 command ID盲重试。新 ID 会绕过原命令的幂等记录。


上线前的破坏性验证清单 ​

给同一个删除动作连续制造这些情况:

  1. 用户双击确认按钮;
  2. Agent 重复发送相同 Tool Call;
  3. 无权限用户伪造前端参数;
  4. 确认后对象版本发生变化;
  5. 数据库提交后故意断开响应;
  6. 用户在 executing 时关闭页面;
  7. 审计写入失败;
  8. 同一 command ID 在另一个进程重放。

验收结果不应该只看聊天卡片,而要同时查询:

text
业务表变化次数
command result 唯一记录
审计记录
Tool Result
刷新后的恢复状态

如果数据库只删除一次、重复调用返回同一结果、未知状态能查询收口、审计能对应 actor 与 command,才算真正跑通。


怎么选 ​

项目现状起点
已有成熟命令总线、权限和审计,只缺 AI 接口手写 action bridge,复用原业务能力
Agent 要频繁操作当前 React 页面,并需要内联工具 UI / 人工暂停CopilotKit
产品核心是 AI 生成并持续更新 React 业务组件Tambo
只是知识问答,没有业务写操作不要引入应用动作框架

框架选择只影响连接层。无论选哪一个,关键写操作都应收敛到同一条服务端命令链:

text
authenticate
  → authorize
  → validate command
  → check idempotency
  → check version / business rules
  → commit transaction
  → write audit
  → return structured result

结语:模型可以建议,只有业务系统能确认事实 ​

三篇文章到这里形成了清晰边界:

text
UI 状态
  页面现在怎样展示和操作

对话状态
  消息、运行、线程和分支进行到哪里

业务状态
  权限是否通过、事务是否提交、现实是否改变

模型可以生成 Proposal,runtime 可以暂停等待确认,UI 可以把过程画得很清楚。但客户是否删除、款项是否支付、邮件是否发送,必须由负责该事实的业务系统回答。

所以有副作用的 AI 功能,最重要的不是“模型会不会调用工具”,而是这句话:

即使 Tool Call 被重复、页面断线、Agent 重试,业务系统仍能只执行一次,并给出可查询、可审计的最终结果。

做到这一点,AI 才真正从“会操作页面的 Demo”进入“可以承担责任的产品”。


📚 核对范围与官方资料

原创声明

本文首发于我的个人博客。如需转载请注明作者及 原文链接。

👀 本文阅读 ··· 次

基于 VitePress 构建

本站总访问量 ··· 次·访客数 ··· 人