diff --git "a/AI\345\244\232\347\247\237\346\210\267Agent\344\270\255\345\217\260-\350\256\276\350\256\241\350\256\250\350\256\272.md" "b/AI\345\244\232\347\247\237\346\210\267Agent\344\270\255\345\217\260-\350\256\276\350\256\241\350\256\250\350\256\272.md" new file mode 100644 index 00000000000..f7a0af8c525 --- /dev/null +++ "b/AI\345\244\232\347\247\237\346\210\267Agent\344\270\255\345\217\260-\350\256\276\350\256\241\350\256\250\350\256\272.md" @@ -0,0 +1,380 @@ +# 基于 CopilotKit 构建多租户 AI Agent 中台 —— 设计讨论纪要 + +> 本文整理自一次关于"如何基于 CopilotKit 为多个现有业务系统构建 AI 赋能能力"的设计讨论。 +> 所有结论均对应 CopilotKit 仓库中真实存在的机制,并标注了关键源码位置。 + +--- + +## 目录 + +1. [背景与目标](#1-背景与目标) +2. [可行性:独立的 AI Agent 后端](#2-可行性独立的-ai-agent-后端) +3. [多租户 Agent 后端(一套后端对接多个业务系统)](#3-多租户-agent-后端一套后端对接多个业务系统) +4. [AI 中台定位与"是否每个系统都要专用 Agent"](#4-ai-中台定位与是否每个系统都要专用-agent) +5. [兼容各业务系统不同的权限体系](#5-兼容各业务系统不同的权限体系) +6. [业务 API 是否需要暴露给前端](#6-业务-api-是否需要暴露给前端) +7. [前端多步 UI 编排场景(导航→等渲染→弹窗)](#7-前端多步-ui-编排场景导航等渲染弹窗) +8. [关键源码索引](#8-关键源码索引) + +--- + +## 1. 背景与目标 + +- 目标:为**多个现有业务系统**(可能分属不同领域、差别很大)构建 AI 辅助能力。 +- 约束:AI Agent 的后端**不放在原有业务系统后端**里,而是一个**独立项目**。 +- 设想:把这个独立 AI 后端做成**多租户**形态(每个业务系统 = 一个租户),会话按 **租户 + 用户 + 会话Id** 管理,从而同时对接多个业务系统。 + +--- + +## 2. 可行性:独立的 AI Agent 后端 + +**结论:完全可行,且是 CopilotKit 推荐的部署方式之一。** + +CopilotKit 三层之间全部通过 **AG-UI 协议(基于 SSE 的事件流)+ HTTP** 通信,而非进程内调用,因此三层物理可分离: + +``` +前端 (React/Angular/Vue/RN) + │ runtimeUrl = https://ai.yourcompany.com + ▼ +独立 AI 服务 (CopilotKit Runtime + Agent) ← 全新独立项目 + │ + ▼ +业务后端 API (Runtime/Agent 通过 HTTP 调用) +``` + +- **前端**通过 `` 指向任意 URL,可指向独立 AI 服务。 +- **Runtime**(`CopilotRuntime`)是独立 HTTP 服务,提供 Express/Hono/Node 适配器。 +- **Agent 后端**可再独立,Runtime 通过 remote endpoints 转发。 + +### 对接现有业务系统的关注点 + +1. **CORS**:跨源时 Runtime 需允许业务前端域名。 +2. **鉴权/身份透传**:用 `beforeRequestMiddleware` 校验业务前端 token,并把身份注入后续 agent 调用。 +3. **AI 如何读写业务数据**:前端工具(浏览器执行)/ 后端工具(服务端执行)/ Context(上下文)三者可组合。 +4. **会话持久化**:独立 AI 服务自行管理 thread(内存 / SQLite / 自定义 Runner),不依赖业务后端。 +5. **前端框架**:React、Vue、Angular、React Native 均有现成包。 + +--- + +## 3. 多租户 Agent 后端(一套后端对接多个业务系统) + +**结论:可行,Runtime 已内置多租户所需的几乎所有挂钩点。** + +| 需求 | CopilotKit 现成机制 | +|---|---| +| 一套后端对接多个业务系统(每个 = 租户) | `agents` 配置支持**按请求动态解析的工厂函数**,JSDoc 明确写"用于多租户场景" | +| 租户识别 + 鉴权 | `beforeRequestMiddleware` 统一鉴权、解析 `tenantId`/`userId` | +| 会话按 租户+用户+会话Id 管理 | `AgentRunner` 以 `threadId` 为唯一 key | +| 持久化 | 自定义 `AgentRunner`(参考 `@copilotkit/sqlite-runner`)或内置 Intelligence 模式 | + +### agents 工厂(多租户入口) + +```ts +agents: ({ request }) => { + const tenantId = request.headers.get("x-tenant-id"); + return { default: createAgentForTenant(tenantId) }; +} +``` +> 源码 JSDoc:`packages/runtime/src/v2/runtime/core/runtime.ts`(`AgentsFactory` / `resolveAgents`)。 + +### 会话"租户+用户+会话"三级 key 的两种实现 + +- **方式 A(推荐起步)**:复合 `threadId`,服务端命名空间化为 `"{tenantId}:{userId}:{conversationId}"`,兼容现有接口。 +- **方式 B(更彻底)**:实现自定义 `AgentRunner`(实现 `run/connect/isRunning/stop` 四个方法),内部存到自己的 Postgres/Redis/Mongo,并按 `(tenant, user, conversation)` 建索引。`@copilotkit/sqlite-runner` 是现成参考。 + +### 持久化选型 + +- `InMemoryAgentRunner`:进程内、易失、全局 `Map`,**仅适合开发**,不可用于生产多租户(重启即丢、无法横向扩展)。 +- `@copilotkit/sqlite-runner`:单机持久化。 +- 自定义 Runner:生产级,接自己的数据库。 +- 内置 Intelligence 模式:持久化线程 + Redis 锁 + `identifyUser`,但属托管/付费能力(需 license token)。 + +### 安全注意事项(重要) + +1. **`threadId` 来自客户端,必须做归属校验**:强制 `(tenantId,userId)` 与 `threadId` 前缀一致(或在 Runner 查询时强制带 tenant/user 过滤),防止跨租户读取他人会话。 +2. **租户级隔离**:工厂里按租户隔离工具与可访问 API。 +3. **资源与限流**:按 `tenantId` 限流/计费。 +4. **多副本部署**:不能用内存 Runner,需共享存储。 +5. **CORS**:配置多个业务前端域名。 + +--- + +## 4. AI 中台定位与"是否每个系统都要专用 Agent" + +### 它是不是中台?——是,本质是"AI 能力中台 / Agent 网关" + +| 中台层(一套,所有业务系统共享) | 领域层(每个业务系统各自定义) | +|---|---| +| Runtime / SSE 协议、会话线程管理(`AgentRunner`) | Agent 的 system prompt / 人设 | +| 鉴权与租户路由中间件 | 该领域可用的工具集(tools) | +| 持久化、限流、计费、审计、可观测性 | 该领域的业务 API 适配 | +| LLM 接入与模型路由、Generative UI、HITL | 领域知识 / RAG 数据源 | + +中台沉淀"**跑 agent 的基础设施 + 横切治理能力**";领域差异是"**agent 的配置 + 工具 + 数据**"。 + +### 是否要为每个业务系统开发专用 Agent? + +- **专用的 agent 定义:基本需要**(prompt、工具、数据源各不同,"万能 agent"效果差)。 +- **专用的代码库/部署:不需要**(同一个 Runtime 用 `agents` 工厂按租户区分)。 + +**推荐做成"配置驱动 + 插件化"**: + +1. 能配置就别写代码:多数领域 agent 可由 `{ prompt, model, tools[], dataSources[] }` 描述,用 `BuiltInAgent` 实例化。 +2. 每个领域是一个"插件包",复杂领域(LangGraph 等)再单独写,通过统一注册接口接入。 +3. 工具按租户隔离:工厂里只注入该租户该用户允许的工具。 + +> 结论:**中台代码一套;领域 agent 是"配置 + 可选插件代码",随业务系统数量线性增加但每个很薄。** + +--- + +## 5. 兼容各业务系统不同的权限体系 + +### 核心原则 + +> **AI 中台不要重新实现一套权限系统,而要"透传/复用"每个业务系统已有的权限。AI 永远不能让用户获得超过他本来就有的权限。** + +原因:LLM 可被 prompt 注入攻击,**绝不能让 LLM 决定权限**,权限必须由原业务系统鉴权兜底。 + +### 三种可组合的落地模式 + +#### 模式 A:前端工具(最优,几乎零改造,天然继承现有权限) + +- 前端工具(`useFrontendTool`)的 handler **跑在用户浏览器**。 +- LLM 只决定"调哪个工具",真正 API 调用由浏览器用**用户当前已登录的 session/token**发出 → 命中业务系统**原本那套权限校验**,无需在 AI 层复制规则。 +- 适用:浏览器本就能调的业务操作。代价:依赖前端在线。 + +#### 模式 B:身份透传到后端工具(On-Behalf-Of / 令牌中继) + +- 后端工具用 `defineTool` 定义,`execute(args)` **只接收工具参数,不直接拿到用户身份**。 +- 关键技巧:**在 `agents` 工厂里按请求构造 agent 与工具,用闭包捕获中间件解析出的用户 token/身份**。 + +``` +请求 → beforeRequestMiddleware 校验业务系统 token → 解析 {tenant, user, 凭证} + → agents 工厂用闭包把"凭证"包进该用户的工具 execute 里 + → 工具调用业务 API 时带上该用户凭证 → 业务系统自己的鉴权生效 +``` + +> 自定义 factory 模式下,身份也可通过 `RunAgentInput.forwardedProps` 流入 `AgentFactoryContext.input`。 + +#### 模式 C:为每个业务系统写"权限适配器"(保持中台通用) + +```ts +interface BusinessSystemAdapter { + // 1. 用业务系统自己的机制验证调用方身份(JWT / OAuth / 内部票据…) + authenticate(req: Request): Promise<{ tenantId: string; userId: string; credential: unknown }>; + + // 2. 返回该用户在该系统"被允许"的工具/操作清单(用于工具裁剪) + listAllowedTools(ctx: { userId: string; credential: unknown }): Promise; + + // 3. 以用户身份调用业务后端 API(令牌中继/换取下游 token) + callApi(ctx: { credential: unknown }, endpoint: string, args: unknown): Promise; +} +``` +> 接一个新系统 = 写一个适配器 + 一份 agent 配置,中台核心不变。 + +### 两道防线(纵深防御,务必都做) + +1. **工具可用性裁剪**(第一道,体验/优化):工厂里只注入 `listAllowedTools` 返回的工具,LLM 看不到无权限的操作。 +2. **调用时的真实鉴权**(第二道,安全底线,不可省):工具调业务 API 时仍走业务系统自己的权限校验。**即使 LLM 被注入、编造工具调用,最终也会被业务 API 拒绝。** + +### 额外注意事项 + +- **最小权限**:一律用"用户本人凭证",不要用超级管理员 token 代办所有租户。 +- **令牌时效**:注意长流式过程中的 token 过期/刷新。 +- **审计**:按 `tenant + user + conversation + tool 调用` 记录完整审计日志。 +- **prompt 注入红线**:永远假设 LLM 输出不可信,权限判定只信业务系统。 + +--- + +## 6. 业务 API 是否需要暴露给前端 + +**结论:做成"后端工具"则完全不需要向前端暴露。** 业务 API 留在服务端,浏览器看不到也访问不到。 + +要分清两件不同的事: + +> "把工具暴露给 LLM" ≠ "把业务 API 暴露给前端/浏览器"。 + +### 后端工具(backend tool)——业务 API **不**暴露给前端 + +- 定义在 AI 服务端(`defineTool`),`execute` 跑在 AI 后端进程。 +- LLM 看到的只是工具 **schema**(`name`/`description`/`parameters`),不是 API 地址、不是凭证。 +- 真正调用是 **AI 后端 → 业务后端的服务器间调用**,浏览器全程不参与。 + +``` +浏览器 ──(只发消息)──▶ AI 后端(Runtime + Agent) + │ execute() 在这里运行 + └──(服务端到服务端,带用户凭证)──▶ 业务系统 API +``` + +### 前端工具(frontend tool)——handler 在浏览器跑 + +- 注册在前端(`useFrontendTool`),工具 **schema** 随 `RunAgentInput.tools` 发给后端 LLM。 +- `execute/handler` 跑在浏览器,调用的 API 必须是浏览器本就能访问的(通常即业务前端原有接口,复用用户已登录态)。 + +### 怎么选 + +| 维度 | 后端工具 | 前端工具 | +|---|---|---| +| handler 运行位置 | AI 服务端 | 浏览器 | +| 业务 API 是否需对前端暴露 | **否**(服务端到服务端) | 复用前端**已有**的可达接口 | +| 适合的操作 | 内部 API、敏感数据、批量/长耗时、需保密凭证 | 操作当前页面 UI、已登录态下的轻量调用、纯前端动作 | +| 权限继承方式 | 令牌中继 / On-Behalf-Of(工厂闭包捕获凭证) | 天然继承浏览器里用户的现有会话 | + +> 安全提醒:工具的 `description`/`parameters` 会进入发给 LLM 的上下文,**不要在其中写入密钥、内部地址**(这些只放在服务端 `execute` 实现里)。 + +--- + +## 7. 前端多步 UI 编排场景(导航→等渲染→弹窗) + +**场景:** 在 AI 对话框里,需要先跳转到别的页面(路由),页面渲染好后再"点击新页面里的一个按钮弹出对话框"。 + +### 前端工具"传给后端的"是什么? + +完整类型: + +```ts +type FrontendTool = { + name: string; + description?: string; + parameters?: StandardSchemaV1; + handler?: (args: T, context: FrontendToolHandlerContext) => Promise; + followUp?: boolean; + agentId?: string; +}; +``` + +> **传给后端/LLM 的只有 `name` + `description` + `parameters`(转成 JSON Schema)**;`handler` 永远留在浏览器。 +> 即"传给后端的 tool" = 一个声明 `{ 工具名, 自然语言描述, 参数schema }`。 + +### 关键设计原则:别让 LLM 编排底层 UI 步骤 + +不要定义 `navigate`/`clickButton`/`openDialog` 三个工具让 LLM 依次调用(非确定性、时序不可控、模拟点击脆弱)。 + +**正确做法:暴露一个"语义级/意图级"工具**(如 `openCreateOrderDialog`),把多步 UI 编排封装进 handler(确定性代码)。 + +### 实现要点 + +1. **用全局状态/意图驱动 UI**,而不是模拟点击;真实按钮与 AI 触发共用同一状态路径。 +2. **注册在不随路由卸载的根组件**(`useFrontendTool` 在组件卸载时会注销工具)。 +3. 需要"等渲染完成"时用 deferred/事件总线做握手,让工具结果如实反馈。 + +### 参考实现(Next.js App Router + Zustand 举例) + +#### 1) 跨路由的"待执行意图" store + +```tsx +import { create } from "zustand"; + +type PendingIntent = { type: "openCreateOrderDialog"; payload?: Record } | null; + +export const useUiIntent = create<{ + pending: PendingIntent; + setPending: (i: PendingIntent) => void; + clear: () => void; +}>((set) => ({ + pending: null, + setPending: (pending) => set({ pending }), + clear: () => set({ pending: null }), +})); +``` + +#### 2) 在根布局注册语义工具(注册位置稳定,不随路由卸载) + +```tsx +"use client"; +import { useFrontendTool } from "@copilotkit/react-core"; +import { useRouter } from "next/navigation"; +import { z } from "zod"; +import { useUiIntent } from "./ui-intent-store"; + +export function AiUiTools() { + const router = useRouter(); + const setPending = useUiIntent((s) => s.setPending); + + useFrontendTool({ + name: "openCreateOrderDialog", + description: "跳转到订单页并打开'创建订单'对话框。当用户想新建订单时调用。", + parameters: z.object({ + customerId: z.string().optional().describe("可选,预填的客户ID"), + }), + handler: async (args) => { + setPending({ type: "openCreateOrderDialog", payload: args }); // 1) 记下意图(跨路由不丢) + router.push("/orders"); // 2) 路由跳转 + return { ok: true, navigatedTo: "/orders" }; // 3) 结果回传给 LLM + }, + }); + + return null; +} +``` + +#### 3) 目标页挂载后消费意图,打开对话框 + +```tsx +"use client"; +import { useEffect, useState } from "react"; +import { useUiIntent } from "./ui-intent-store"; + +export default function OrdersPage() { + const { pending, clear } = useUiIntent(); + const [dialogOpen, setDialogOpen] = useState(false); + + useEffect(() => { + if (pending?.type === "openCreateOrderDialog") { + setDialogOpen(true); // 等于"按钮被点了",但更可靠 + clear(); + } + }, [pending, clear]); + + return ( + <> + {/* 页面内容…那个"新建订单"按钮 onClick 也只是 setDialogOpen(true) */} + {dialogOpen && setDialogOpen(false)} />} + + ); +} +``` + +> 要点:真实按钮的 onClick 也应只是 `setDialogOpen(true)`,使"用户点击"与"AI 触发"走同一条状态驱动路径,行为一致。 + +#### 进阶:让 handler 真正"等渲染完成再返回" + +```ts +let resolver: (() => void) | null = null; +export const intentBus = { + wait: () => new Promise((res) => (resolver = res)), + done: () => { resolver?.(); resolver = null; }, +}; + +// handler 内: +handler: async (args) => { + setPending({ type: "openCreateOrderDialog", payload: args }); + router.push("/orders"); + await Promise.race([ + intentBus.wait(), + new Promise((_, rej) => setTimeout(() => rej(new Error("打开超时")), 8000)), + ]); + return { ok: true, dialogOpened: true }; +}, +// 目标页 setDialogOpen(true) 之后调用 intentBus.done() +``` + +--- + +## 8. 关键源码索引 + +| 主题 | 文件 / 符号 | +|---|---| +| Runtime 配置、`agents` 工厂(多租户入口)、中间件类型 | `packages/runtime/src/v2/runtime/core/runtime.ts`(`AgentsFactory`、`resolveAgents`、`CopilotRuntime`) | +| 请求中间件(鉴权/租户解析) | `packages/runtime/src/v2/runtime/core/middleware.ts`(`beforeRequestMiddleware` / `afterRequestMiddleware`) | +| 会话状态抽象(以 `threadId` 为 key) | `packages/runtime/src/v2/runtime/runner/agent-runner.ts` | +| 内存 Runner(开发用,全局 Map) | `packages/runtime/src/v2/runtime/runner/in-memory.ts` | +| 持久化 Runner 参考实现 | `packages/sqlite-runner/src/sqlite-runner.ts` | +| 后端工具定义、`defineTool`、`forwardedProps` 覆盖 | `packages/runtime/src/agent/index.ts`(`ToolDefinition`、`defineTool`、`AgentFactoryContext`) | +| 前端工具类型(handler 留在浏览器) | `packages/core/src/types.ts`(`FrontendTool`、`FrontendToolHandlerContext`) | +| 前端工具注册 Hook(卸载即注销) | `packages/react-core/src/v2/hooks/use-frontend-tool.tsx` | +| 只把 schema 发给后端 | `packages/core/src/core/run-handler.ts`(`buildFrontendTools`) | + +--- + +*本文档为设计讨论纪要,供规划参考;落地时请以仓库最新源码为准。*