Skip to content
Draft
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
380 changes: 380 additions & 0 deletions AI多租户Agent中台-设计讨论.md
Original file line number Diff line number Diff line change
@@ -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 调用)
```

- **前端**通过 `<CopilotKit runtimeUrl="...">` 指向任意 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<string[]>;

// 3. 以用户身份调用业务后端 API(令牌中继/换取下游 token)
callApi(ctx: { credential: unknown }, endpoint: string, args: unknown): Promise<unknown>;
}
```
> 接一个新系统 = 写一个适配器 + 一份 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<T> = {
name: string;
description?: string;
parameters?: StandardSchemaV1<any, T>;
handler?: (args: T, context: FrontendToolHandlerContext) => Promise<unknown>;
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<string, unknown> } | 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 && <CreateOrderDialog onClose={() => setDialogOpen(false)} />}
</>
);
}
```

> 要点:真实按钮的 onClick 也应只是 `setDialogOpen(true)`,使"用户点击"与"AI 触发"走同一条状态驱动路径,行为一致。

#### 进阶:让 handler 真正"等渲染完成再返回"

```ts
let resolver: (() => void) | null = null;
export const intentBus = {
wait: () => new Promise<void>((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`) |

---

*本文档为设计讨论纪要,供规划参考;落地时请以仓库最新源码为准。*