AI 能改页面之后,谁为这次操作负责?
前两篇分别处理了“页面怎么看”和“对话进行到哪里”。最后一篇进入真正危险的地方:用户说“删除客户 A”,AI 发起 Tool Call,聊天里还显示了一个绿色的“执行成功”。
客户 A 就真的被删除了吗?
🎯 本文目标
读完你应该能:分清 Tool Call、人工确认和业务事务;设计 proposed → awaiting_approval → executing → succeeded / failed / unknown 状态机;理解为什么权限、幂等、版本校验和审计必须留在服务端;最后看懂手写 action bridge、CopilotKit 与 Tambo 帮你连接了什么,又没有替你负责什么。
本篇故意不讲什么
同一个“删除”,会经过三种不同的成功
用户对 CRM 助手说:
删除客户 A。
系统可能依次出现:
- 页面弹出确认卡片;
- Agent 生成
deleteCustomer({ customerId: 'A' }); - 用户点击确认;
- 前端调用删除接口;
- 数据库删除记录并写审计;
- Tool Result 回填“成功”。
这六步至少包含三种不同事实:
| 表面现象 | 它真正证明了什么 | 归属 |
|---|---|---|
| 确认卡片显示出来 | 页面正在展示确认 UI | UI 状态 |
| Tool Call 参数解析完成 | Agent 提出了一个动作 | 对话状态 |
| 用户点击“确认” | 用户表达了同意 | 对话输入 / 业务命令前置条件 |
| HTTP 返回 200 | 该请求收到成功响应 | 传输结果,还要看业务语义 |
| 数据库事务提交 | 客户记录发生真实变化 | 业务状态 |
| Tool Result 写入消息 | 对话记录了执行结果 | 对话状态 |
最危险的误区是:
Tool Call complete
≠
Business transaction committedTool Call 只是模型和应用之间的协议事件。它可以参数错误、越权、重复执行,也可能在数据库已经提交后丢失响应。绿色卡片只是一个投影,不能成为最终真相。
业务状态机必须比 loading 更诚实
对有副作用的动作,loading / success / error 不够。最小状态机应该是:
proposed
↓
awaiting_approval ──拒绝──→ rejected
↓ 同意
executing
├──→ succeeded
├──→ failed
└──→ unknown ──查询命令状态──→ succeeded / failed为什么需要 unknown
假设服务端已经提交删除事务,但返回响应前网络断了:
- 浏览器看到
fetch failed; - Agent 看不到 Tool Result;
- 客户实际上已经删除。
此时显示“删除失败”是撒谎,直接重试又可能重复执行。正确状态是 unknown:我不知道响应,但我知道命令可能已经发生,必须用命令 ID 查询。
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 必须是三个对象
把模型生成的参数直接当命令执行,会丢失审查和重放边界。更稳的模型是:
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。
人工确认不是权限校验
用户点击“确认删除”只能说明他同意,不能证明他有权限。前端传来的这些字段都不可信:
{
"userId": "admin",
"isAdmin": true,
"approved": true
}服务端必须从可信 session / token 重新得到身份,并重新检查:
- 当前用户是否能访问这个 tenant;
- 是否有删除客户权限;
- 客户是否仍存在;
- 客户版本是否仍与确认时一致;
- 是否有未结清订单等业务约束;
- command ID 是否已经执行过。
确认 UI 解决的是“避免模型擅自做决定”,服务端鉴权解决的是“这个人是否允许做决定”。它们不能互相替代。
幂等:用户点两次、Agent 重试两次,也只能执行一次
AI 场景特别容易重复提交:
- 用户双击确认;
- 前端超时自动重试;
- Agent 因没收到 Tool Result 再调一次工具;
- 消息恢复后重新播放未完成步骤;
- 网关重放请求。
因此每个业务命令需要稳定的 commandId / idempotency key:
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,而是四件事在同一个可信边界完成:
幂等检查 + 权限校验 + 业务写入 + 审计记录如果“删除成功”提交了,审计却另起一个异步请求并失败,系统就失去可追溯性。高风险业务应让关键审计与事务保持一致,或采用可靠 outbox 等机制。
版本校验:确认的是刚才看到的对象,不是任意未来状态
用户确认时看到:
客户 A,版本 12,无未结订单确认后到执行前,另一个用户可能创建了新订单。如果命令只带 customerId,删除会作用于一个已经变化的对象。
所以 Command 应携带确认时的 expectedVersion,服务端执行时做乐观并发控制:
DELETE FROM customers
WHERE id = :customerId
AND version = :expectedVersion;影响行数为 0 时,不应该自动换成最新版本重试,而应回到 failed/conflict,让用户基于新事实重新确认。
人工确认也会过期
审批不是一张永久通行证。参数被修改、对象版本变化、权限变化、确认超时后,都应该产生新 Proposal 或重新审批。
Tool Result 应该回填事实,而不是情绪化文案
不推荐:
{ "result": "搞定啦,客户已经删除!" }更适合对话层记录的是结构化结果:
{
"commandId": "cmd-20260827-001",
"status": "succeeded",
"customerId": "customer-A",
"committedAt": "2026-08-27T10:30:00+08:00"
}UI 可以把它渲染成人话,Agent 也可以据此继续总结;但数据库和审计仍是业务真相。Tool Result 是真相的对话投影,不是新的真相来源。
失败同样要稳定编码:
{
"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
最小桥接由三部分组成:
type ActionDefinition<T> = {
name: string
description: string
parse: (input: unknown) => T
risk: 'ui-only' | 'read' | 'write' | 'critical'
execute: (input: T, context: ActionContext) => Promise<unknown>
}执行器按风险决定是否审批:
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 注册浏览器工具:
npm install @copilotkit/react-core @copilotkit/runtimeimport { 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:
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:
npx tambo full-send
# 或手动安装 SDK
npm install @tambo-ai/reactconst 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 帮助模型选择、渲染和更新组件,不会替业务服务承担事务真相。
一张责任表看清三种连接方式
| 能力 | 手写 bridge | CopilotKit | Tambo |
|---|---|---|---|
| 工具注册与 schema | 自己实现 | useFrontendTool / Zod | TamboTool / 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,通过独立查询接口确认:
GET /api/commands/cmd-20260827-001{
"commandId": "cmd-20260827-001",
"status": "succeeded",
"result": {
"customerId": "customer-A"
}
}不要生成一个新 command ID盲重试。新 ID 会绕过原命令的幂等记录。
上线前的破坏性验证清单
给同一个删除动作连续制造这些情况:
- 用户双击确认按钮;
- Agent 重复发送相同 Tool Call;
- 无权限用户伪造前端参数;
- 确认后对象版本发生变化;
- 数据库提交后故意断开响应;
- 用户在 executing 时关闭页面;
- 审计写入失败;
- 同一 command ID 在另一个进程重放。
验收结果不应该只看聊天卡片,而要同时查询:
业务表变化次数
command result 唯一记录
审计记录
Tool Result
刷新后的恢复状态如果数据库只删除一次、重复调用返回同一结果、未知状态能查询收口、审计能对应 actor 与 command,才算真正跑通。
怎么选
| 项目现状 | 起点 |
|---|---|
| 已有成熟命令总线、权限和审计,只缺 AI 接口 | 手写 action bridge,复用原业务能力 |
| Agent 要频繁操作当前 React 页面,并需要内联工具 UI / 人工暂停 | CopilotKit |
| 产品核心是 AI 生成并持续更新 React 业务组件 | Tambo |
| 只是知识问答,没有业务写操作 | 不要引入应用动作框架 |
框架选择只影响连接层。无论选哪一个,关键写操作都应收敛到同一条服务端命令链:
authenticate
→ authorize
→ validate command
→ check idempotency
→ check version / business rules
→ commit transaction
→ write audit
→ return structured result结语:模型可以建议,只有业务系统能确认事实
三篇文章到这里形成了清晰边界:
UI 状态
页面现在怎样展示和操作
对话状态
消息、运行、线程和分支进行到哪里
业务状态
权限是否通过、事务是否提交、现实是否改变模型可以生成 Proposal,runtime 可以暂停等待确认,UI 可以把过程画得很清楚。但客户是否删除、款项是否支付、邮件是否发送,必须由负责该事实的业务系统回答。
所以有副作用的 AI 功能,最重要的不是“模型会不会调用工具”,而是这句话:
即使 Tool Call 被重复、页面断线、Agent 重试,业务系统仍能只执行一次,并给出可查询、可审计的最终结果。
做到这一点,AI 才真正从“会操作页面的 Demo”进入“可以承担责任的产品”。
📚 核对范围与官方资料
- CopilotKit
useFrontendTool、useHumanInTheLoop、v2 Hook 选择指南 - Tambo React SDK、工具类型、组件状态
- 框架文档用于核对工具注册、渲染与暂停能力;权限、幂等、事务和审计部分是通用业务架构,不声称由这些前端框架自动提供。