12 KiB
Agent 文档工具设计
日期:2026-08-09 状态:已确认,待编写实施计划
背景与目标
当前 agent 工具系统已有 14 种工具类型(fetch、calculator、datetime 等),但缺少文档读写能力。agent 无法跨会话持久化重要信息,长对话上下文丢失后无法恢复。
本设计新增 document 工具类型,让 agent 能够:
- 记录:把重要信息、决策、中间结论写入文档,后续会话可读取(解决上下文丢失)
- 产出:生成面向用户交付的文档(如周报、需求文档、会议纪要)
核心诉求是"优化上下文与记录"——通过摘要 + token 预算控制,避免把所有历史记录塞进上下文。
设计决策
| 决策项 | 选择 | 理由 |
|---|---|---|
| 核心定位 | 记录 + 产出兼顾 | 既作 agent 内部记忆,也作交付物 |
| 存储模型 | 新建独立表 | 与 articles 解耦,不污染文章系统语义 |
| 工具粒度 | 单工具 + 内部分发 | agent 侧一个工具,内部按 action 分发到子文件 |
| 上下文优化 | 摘要 + token 预算 | 零额外依赖,后续可演进到向量检索 |
| 权限范围 | 用户级隔离 | agent 只能读写当前用户的文档 |
| 文档类型 | memory/document 二分 | memory=内部记忆,document=交付物 |
| 交付物处理 | 独立查看 | 不与 articles 打通,前端独立查看页 |
| 审批策略 | config 危险操作白名单 | delete 默认需审批,复用现有审批基础设施 |
| 前端范围 | 后端 + 查看页 | 列表 + 详情(markdown 渲染 + 编辑) |
第 1 章:数据模型
新建表:agent_documents
文件位置:packages/drizzle-pkg/lib/schema/agent-document.ts
| 字段 | 类型 | 约束 | 说明 |
|---|---|---|---|
id |
text | PK | ad_${timestamp36}_${random} |
userId |
integer | NOT NULL | 所属用户(隔离边界) |
sessionId |
text | nullable | 关联会话(memory 类型用) |
type |
text enum | NOT NULL | memory | document |
title |
text(255) | NOT NULL | 标题 |
content |
text | NOT NULL | 正文(markdown) |
summary |
text | nullable | 摘要(agent 写入时生成,或后端截断前 200 字符) |
tags |
text(500) | nullable | 逗号分隔标签 |
createdAt |
timestamp_ms | NOT NULL, defaultNow | 创建时间 |
updatedAt |
timestamp_ms | NOT NULL, defaultNow, onUpdate | 更新时间 |
索引
idx_agent_doc_user_typeON (userId, type) — 按用户+类型查询idx_agent_doc_user_createdON (userId, createdAt) — 按用户+时间排序idx_agent_doc_sessionON (sessionId) — 按会话查询
设计说明
- 不设
status字段:memory/document 都不需要 draft/published 语义 - 不设
visibility字段:用户级隔离,无需多级可见性 summary由 agent 在 create/update 时主动传入;若未传,后端取 content 前 200 字符sessionId只能由 agent 工具内部设置,API 不暴露该字段
第 2 章:工具 Executor 设计
文件结构
server/service/agent-tool/executors/document/
├── config.ts # DocumentToolConfig(zod)+ DEFAULT_CONFIG
├── document.ts # 主 executor,内部分发到各操作
├── operations/
│ ├── create.ts # 创建文档
│ ├── read.ts # 读取/搜索文档(摘要 + token 预算)
│ ├── update.ts # 更新文档
│ ├── delete.ts # 删除文档
│ └── list.ts # 列表(轻量,只返回 id/title/summary/type/updatedAt)
└── token-budget.ts # token 预算计算工具
Input Schema(zod)
z.object({
action: z.enum(["create", "read", "update", "delete", "list"]),
// create / update 共用
title: z.string().max(255).optional(),
content: z.string().optional(),
type: z.enum(["memory", "document"]).optional(),
summary: z.string().optional(),
tags: z.string().optional(),
sessionId: z.string().optional(),
// read 单个 / update / delete
id: z.string().optional(),
// read / list 搜索
q: z.string().optional(),
filterType: z.enum(["memory", "document"]).optional(),
page: z.number().int().min(1).optional(),
pageSize: z.number().int().min(1).max(50).optional(),
// read 单个时是否返回完整内容
fullContent: z.boolean().optional(),
}).superRefine((val, ctx) => {
// 按 action 校验必填字段:
// create: title + content 必填
// read: id 或 q 至少一个
// update: id + 至少一个可更新字段
// delete: id 必填
// list: 无必填
});
Config
interface DocumentToolConfig {
maxContentLength: number; // content 最大字符数,默认 50000
defaultSummaryLength: number; // 默认摘要截断长度,默认 200
tokenBudget: number; // read/list 返回的总 token 预算,默认 2000
maxPageSize: number; // list 最大每页条数,默认 20
approvalRequiredActions: string[]; // 需要审批的操作,默认 ["delete"]
}
const DEFAULT_DOCUMENT_CONFIG: DocumentToolConfig = {
maxContentLength: 50000,
defaultSummaryLength: 200,
tokenBudget: 2000,
maxPageSize: 20,
approvalRequiredActions: ["delete"],
};
buildDescription
根据 config 动态生成,告知 agent 可用操作、类型选项、token 预算限制、需审批的操作。
execute 内部分发
async execute(input, config, ctx): Promise<ToolResult> {
// 审批检查
if (config.approvalRequiredActions.includes(input.action) && !ctx.approved) {
return {
success: false,
error: `操作 "${input.action}" 需要用户审批`,
metadata: { durationMs: 0, needsApproval: true },
};
}
// 未登录检查
if (ctx.userId === null || ctx.userId === undefined) {
return {
success: false,
error: "该工具需要登录会话",
metadata: { durationMs: 0 },
};
}
switch (input.action) {
case "create": return await createDoc(input, config, ctx);
case "read": return await readDoc(input, config, ctx);
case "update": return await updateDoc(input, config, ctx);
case "delete": return await deleteDoc(input, config, ctx);
case "list": return await listDocs(input, config, ctx);
}
}
每个操作函数独立文件,接收 (input, config, ctx) 返回 ToolResult。
第 3 章:各操作行为与 token 预算
create
- 校验 title(必填,≤255)、content(必填,≤maxContentLength)、type(默认
memory) - summary:若 agent 传入则用,否则取 content 前
defaultSummaryLength字符 - sessionId:若 agent 传入则存,否则 NULL
- 写入 DB,返回
{ id, title, type, createdAt }
read(单个)
- 按 id + userId 查询(隔离)
- 默认返回 summary + metadata;若
fullContent=true则返回完整 content - 若 content 超过 tokenBudget 且未指定 fullContent,返回 summary 并提示 "内容过长,已返回摘要,如需完整内容请指定 fullContent=true"
list(搜索/列表)
- 按 userId + filterType + q(title/content LIKE)过滤
- 分页(默认 page=1, pageSize=10, maxPageSize=20)
- 返回数组:
[{ id, title, summary, type, tags, updatedAt }](不含 content) - token 预算控制:累加每条 summary 的字符数,超过
tokenBudget * 4(约 1 token ≈ 4 字符)则截断并返回hasMore: true+ 下一页提示
update
- 按 id + userId 查询(隔离,只能改自己的)
- 支持更新 title/content/summary/tags
- 若 content 更新且未传 summary,自动重新截断
- 返回
{ id, title, updatedAt }
delete
- 按 id + userId 查询(隔离)
- 硬删除
- 返回
{ id, deleted: true }
权限与隔离
- 所有操作都强制
WHERE userId = ctx.userId - 若 ctx.userId 为 null(未登录),直接返回错误
- 不做 admin 跨用户读写
第 4 章:审批机制
现有机制回顾
agent_tools表有needsApproval字段(工具级开关)agent_tool_associations表有needsApproval(agent 维度覆盖工具级)- 审批在
chat-engine.ts中通过approvalToolCallId流程实现,粒度是"工具调用"
单工具审批方案
在 DocumentToolConfig 中增加 approvalRequiredActions: string[],默认 ["delete"]。
executor 在 execute 时检查:若 action 在白名单中且 ctx.approved 不为 true,返回 { success: false, error: "操作需要用户审批", metadata: { needsApproval: true } },触发现有审批流程。
ToolContext 扩展
在 server/service/agent-tool/registry.ts 的 ToolContext 接口中增加 approved?: boolean 字段,由 executeAgentTool 在审批流程中注入。
export interface ToolContext {
toolId: string;
toolSlug: string;
userId: number | null;
approved?: boolean; // 新增:是否已通过审批
}
executeAgentTool 函数在构造 ctx 时,根据调用来源设置 approved:
- 正常调用(非审批流程):
approved = false - 审批通过后继续执行:
approved = true
第 5 章:Service 层与 API
Service 层
新建 server/service/agent-document/index.ts,提供 CRUD 函数(供 executor 和 API 共用):
createDocument(userId: number, input: CreateDocumentInput): Promise<AgentDocumentRow>
getDocumentById(userId: number, id: string): Promise<AgentDocumentRow | null>
listDocuments(userId: number, opts: ListDocumentsOptions): Promise<{ items, total, page, pageSize, hasMore }>
updateDocument(userId: number, id: string, input: UpdateDocumentInput): Promise<AgentDocumentRow | null>
deleteDocument(userId: number, id: string): Promise<boolean>
所有函数内部都强制 WHERE userId = ?,executor 和 API 都调用这些函数,确保隔离逻辑单点维护。
API 路由
server/api/agent-documents/
├── index.get.ts # 列表(分页 + type/q 过滤)
├── index.post.ts # 创建(用户手动创建文档,非 agent 调用)
├── [id].get.ts # 详情
├── [id].put.ts # 更新
└── [id].delete.ts # 删除
- 所有 API 都从会话取 userId,未登录返回 401
- API 不暴露 sessionId 字段(sessionId 只能由 agent 工具内部设置)
- API 用于前端查看页的增删改查
前端查看页
app/pages/agent-documents/index.vue:文档列表页,按 type 分 tab(memory/document),支持搜索app/pages/agent-documents/[id].vue:文档详情页,markdown 渲染(复用现有 bytemd),支持编辑
第 6 章:注册集成
- Schema:在
packages/drizzle-pkg/lib/schema/agent-document.ts新建表,在packages/drizzle-pkg/lib/schema/index.ts导出 - 迁移:运行
bun run db:generate+bun run db:migrate - Executor 注册:在
server/service/agent-tool/index.ts的TOOL_TYPE_REGISTRY中添加document条目,import 各文件 - AgentToolTypes 枚举:在
packages/drizzle-pkg/lib/schema/agent-tool.ts的AgentToolTypes数组中添加"document" - ToolContext 扩展:在
registry.ts中增加approved?: boolean字段 - executeAgentTool 扩展:在
index.ts的executeAgentTool中根据调用来源设置ctx.approved - Seed:可选,在 seed 中创建一个默认的 document 工具实例
第 7 章:测试策略
单元测试
各 operation 函数(create/read/update/delete/list)独立测试,mock db。
集成测试
executor 端到端,用测试数据库。
E2E 测试
在现有 e2e/ 框架下新增 specs/agent-document.spec.ts,测试:
- agent 对话中调用 document 工具创建 memory
- 后续会话中 agent 读取该 memory
- 前端查看页可看到 agent 创建的文档
- delete 操作触发审批流程
错误处理
- 输入校验失败:返回 zod 错误信息
- 文档不存在/无权限:返回
success: false, error: "文档不存在或无权访问" - 未登录:返回
success: false, error: "该工具需要登录会话" - content 超长:返回
success: false, error: "内容超过最大长度限制"
演进路径
当前方案 A(纯 SQL + 摘要)的后续演进方向:
- 向量检索:在 summary 字段基础上加 embedding 字段,引入 embedding 模型 + sqlite-vss,实现语义检索
- 分层记忆:memory 细分为 session_memory(绑定 sessionId,会话结束自动归档)和 long_term_memory
- 文档版本:增加版本历史,支持回溯
- 文档分享:增加 visibility 字段,支持 shared/public 可见性