本文由项目沉淀整理而来,已去除具体单位、系统名称、内部链接和业务数据。代码保留了核心逻辑,做了少量简化。
AI 助手做到一定阶段,用户就不满足于“问”了,会开始让它“做”:
帮我把公告置顶。 把这个系统加到收藏。 最近访问那块用不上,关了吧。
从只读查询走向写操作,模型侧几乎不用改,大模型本来就会调用工具。真正要花心思的是工具本身:用户说的名字怎么对上数据库里的 ID?重复操作要不要再写一次?普通用户能不能碰管理员的东西?写完之后页面怎么刷新?模型怎么知道当前页面上有什么?
我们在内部技术门户的助手里陆续上线了一组个人设置类的写工具:首页分区显隐、首页卡片置顶、收藏管理、自定义入口管理。这篇把过程中踩过、补过的工程细节整理出来。
一、先划清边界:什么样的写操作适合交给助手
不是所有写操作都适合让助手做。我们给第一批写工具定了三个条件:
- 只影响用户自己。 置顶、收藏、分区显隐,改的都是当前用户的个人偏好,不影响别人。
- 可逆。 置顶了可以取消,收藏了可以移除,没有“删了就回不来”的东西。
- 结果在页面上立刻可见。 用户能马上看到改没改对,不对再说一句就能改回来。
满足这三条,就可以让助手直接执行,不需要二次确认弹窗。涉及他人数据、不可逆或高风险的动作,应该走另一套更重的确认和审批机制(可以参考MCP 权限分级那篇),不在本文讨论范围内。
二、身份从会话来,不从参数来
这一条最基础,也最容易被忽略。
写工具的 inputSchema 里只有业务参数(哪张卡片、置顶还是取消),没有 userId。执行器从服务端会话拿当前用户,再传给具体的工具函数:
private async executeBuiltinTool(
toolName: string,
args: Record<string, unknown>,
userId: string // 来自服务端会话,不是模型生成的参数
): Promise<unknown> {
if (toolName === USER_PLATFORM_SECTION_VISIBILITY_TOOL_NAME) {
return setUserPlatformSectionVisibility(args, userId);
}
if (toolName === USER_HOME_CARD_PIN_TOOL_NAME) {
return setUserHomeCardPin(args, userId);
}
if (toolName === USER_FAVORITES_TOOL_NAME) {
return manageUserFavorites(args, userId);
}
// ...
throw new Error(`Unknown builtin tool: ${toolName}`);
}
如果把 userId 放进参数,模型就有机会(被注入或者自己犯错)改到别人头上。凡是“操作谁”的信息,都应该由系统决定,而不是由模型决定。
每次执行还会写一条工具调用日志(工具名、入参、成功或失败、耗时),出了问题可以回溯“助手刚才到底干了什么”。
三、工具描述是给模型看的产品文档
我们的每个内置工具除了标准的 name、description、inputSchema,还多了一个 rules 字段,专门写“什么时候调、什么时候不调、调完怎么说”:
{
id: "builtin:set_user_home_card_pin",
type: "builtin",
name: "set_user_home_card_pin",
description:
"置顶或取消置顶当前用户首页的大卡片。首页分左右两栏,各自最多置顶一张卡片," +
"置顶新卡片会自动取消同栏原有置顶。左栏是目录分区(如「AI 中心」「技术平台」" +
"「工单中心」);右栏是「最近访问」「我的收藏」「公告」等,管理员另有「数据看板」。",
inputSchema: {
type: "object",
properties: {
card: {
type: "string",
description: "要置顶/取消置顶的卡片名称,使用用户说的卡片名……",
},
pinned: { type: "boolean", description: "true 表示置顶;false 表示取消置顶。" },
area: {
type: "string",
description: "可选:left 或 right。仅在用户说「取消左边的置顶」而没说具体卡片时传。",
},
},
required: ["pinned"],
},
priority: 10,
rules:
"仅当用户明确要求置顶、取消置顶首页某张卡片时调用。card 尽量保留用户原话中的卡片名;" +
"如果工具返回未找到并给出候选,向用户确认后再调用。执行成功后简洁告知结果," +
"若同栏原有置顶被替换,一并说明。",
}
几个写法上的经验:
- 把业务规则写进 description。 “每栏最多置顶一张,新的会顶掉旧的”这种规则,模型不知道就会在回答里说错。写进描述后,模型会主动告诉用户“原来的置顶已自动取消”。
- 明确“不该调用”的场景。 “不要用于查询资源或解释概念”,可以减少模型把查询类问题误路由到写工具。
- 参数描述里告诉模型传什么形态。 “使用用户说的卡片名”比让模型自己把“公告”翻译成
notice更稳,翻译交给代码做(见下一节)。 - 说明工具返回异常时怎么办。 “返回候选时先向用户确认”,把澄清的责任明确交给模型。
四、用户口语到 ID:归一化、别名、旧称、唯一匹配
用户不会说 quick-prompts,会说“快捷提问”“常用问题”“快捷提问卡片”。名称解析我们完全放在工具代码里做,不依赖模型翻译。
第一步是归一化,去掉空格、引号、书名号,以及“卡片”“分区”“板块”这类后缀:
/** 左栏目录改名前的旧称,用户仍按旧称说时映射到新名 */
const HOME_LEFT_CARD_LEGACY_NAMES: Record<string, string> = {
开发工具: "技术平台",
研发平台: "技术平台",
ai专区: "ai中心",
};
function normalizeCardName(value: string): string {
const name = value
.trim()
.toLowerCase()
.replace(/[\s「」“”"'《》]/g, "")
.replace(/(卡片|分区|区域|板块|模块)$/, "");
return HOME_LEFT_CARD_LEGACY_NAMES[name] ?? name;
}
第二步,固定卡片走别名表,一个 ID 对应多种说法:
const HOME_RIGHT_CARD_ALIASES: Record<string, string> = {
favorite: "favorite",
收藏: "favorite",
我的收藏: "favorite",
"recent-new": "recent-new",
最近上新: "recent-new",
上新: "recent-new",
新上线: "recent-new",
"quick-prompts": "quick-prompts",
快捷提问: "quick-prompts",
常用问题: "quick-prompts",
// ...
};
第三步,后台可配置的目录卡片(名字会变)走“精确优先、模糊兜底、必须唯一”:
const exact = leftCards.filter(c => normalizeCardName(c.name) === key);
const fuzzy =
exact.length > 0
? exact
: leftCards.filter(c => {
const name = normalizeCardName(c.name);
return name.includes(key) || key.includes(name);
});
const [only] = fuzzy;
if (fuzzy.length !== 1 || !only) {
return {
error: true,
message:
fuzzy.length === 0
? `首页没有找到名为「${cardArg}」的卡片`
: `「${cardArg}」匹配到多张卡片,请确认具体是哪一张`,
candidates: (fuzzy.length > 0 ? fuzzy : leftCards)
.map(c => c.name)
.concat(fuzzy.length > 0 ? [] : visibleRightLabels),
};
}
关键在于:匹配不唯一时绝不猜。 写操作猜错的代价比读操作高得多,查错了用户再问一句就行,置顶错了用户得自己发现、再让助手改回来。所以工具返回 error 加 candidates,配合 rules 里“返回候选时向用户确认”,模型会自然地反问:“你是指 A 还是 B?”
收藏这类以资源为对象的操作更严格:入参必须是 resourceId,用户只给名称时,rules 要求先调用搜索工具确认唯一资源,再执行收藏。“不要替用户收藏多个不确定候选”也写进了 rules。
五、幂等:先读状态,没变化就不写
写工具最容易出现这样的对话:
用户:把公告置顶。 助手:已将「公告」置顶到右栏。 (过了一会儿) 用户:把公告置顶。 助手:已将「公告」置顶到右栏。
第二次其实什么都没变,但工具照样写了一次库,模型也照样说“已置顶”。用户会困惑:刚才没成功吗?
修法很简单:写之前先读当前状态,已经是目标状态就直接返回,并带上明确的标记:
if (pinned && cardId && previousId === cardId) {
return {
success: true,
area,
cardLabel: labelOf(area, cardId),
pinned,
alreadyPinned: true,
pinnedSections: current,
message: `「${labelOf(area, cardId)}」已经是${areaLabel}置顶,无需重复操作`,
};
}
收藏也一样,用一个表达式同时覆盖“加了又加”和“删了又删”:
const existingFavorite = await db.favorite.findUnique({
where: { userId_resourceId: { userId, resourceId } },
select: { userId: true },
});
if ((action === "add") === !!existingFavorite) {
return {
success: true,
action,
resourceId,
resourceName: resource.name,
unchanged: true,
message:
action === "add"
? `「${resource.name}」已在收藏中,无需重复收藏`
: `「${resource.name}」当前未收藏,无需取消`,
};
}
这里有两个设计选择:
- 返回
success: true而不是error。 用户的意图(公告是置顶的)已经满足,这不是失败。返回 error 会让模型道歉或重试。 message直接写成给用户看的话。 模型通常会基于 message 组织回答,写清楚“无需重复操作”,模型就不会再说“已为您置顶”。
数据库层面 upsert 本来就是幂等的,重复写不会出错。但工具的幂等不只是“不出错”,还包括“如实告诉用户什么都没变”。另外,收藏成功后还会触发成就检查、刷新全局统计缓存这类副作用,提前返回也顺带避免了这些重复副作用。
六、权限要管四处,候选列表也要过滤
右栏有一张“数据看板”卡片,只有管理员能看到。最早的实现里,普通用户对助手说“把数据看板置顶”,工具照样会执行。结果右栏唯一的置顶位被一张用户自己看不见的卡片占住,用户真正想置顶的卡片反而置顶不上。
修复时我们在两个入口都加了校验。
工具层:
const [leftCards, user] = await Promise.all([
listHomeLeftCards(),
db.user.findUnique({ where: { id: userId }, select: { isAdmin: true } }),
]);
const isAdmin = user?.isAdmin === true;
if (rightId && !isAdmin && ADMIN_ONLY_HOME_CARD_IDS.includes(rightId)) {
return {
error: true,
message: `「${HOME_RIGHT_CARD_LABELS[rightId]}」仅管理员可操作`,
};
}
接口层(页面上的置顶按钮走的是普通接口,不经过助手):
if (input.sectionId && ADMIN_ONLY_HOME_CARD_IDS.includes(input.sectionId)) {
const user = await ctx.db.user.findUnique({
where: { id: ctx.session.user.id },
select: { isAdmin: true },
});
if (!user?.isAdmin) {
throw new TRPCError({ code: "FORBIDDEN", message: "该卡片仅管理员可操作" });
}
}
容易漏的是第三处:返回给模型的候选列表。匹配失败时工具会列出所有可选卡片,如果列表里包含“数据看板”,模型会很自然地建议普通用户“你可以置顶数据看板”。所以候选列表也按权限过滤:
const visibleRightLabels = Object.entries(HOME_RIGHT_CARD_LABELS)
.filter(([id]) => isAdmin || !ADMIN_ONLY_HOME_CARD_IDS.includes(id))
.map(([, label]) => label);
再往上一层,整个工具也可以按角色裁剪。比如运营数据查询工具标了 adminOnly: true,普通用户的工具列表里根本不会出现它:
export function getBuiltinTools(isAdmin = false): PrioritizedBuiltinTool[] {
return BUILTIN_TOOLS.filter(tool => isAdmin || !tool.adminOnly).map(tool => ({
...tool,
}));
}
权限检查都是实时查库,不依赖会话里缓存的角色,管理员被降级后立刻生效。总结下来,权限要覆盖四个位置:工具是否可见、工具执行、普通接口、返回给模型的提示信息。
七、页面上下文:让模型知道用户在看什么
用户在首页说“把这个关了”,模型需要知道“这个”是什么,也需要知道当前页面有哪些板块、用户隐藏了哪些分区。我们每次发消息时把页面上下文带给服务端,拼进系统提示词:
## 当前页面
用户发送本条消息时所在的页面:
- 页面:技术门户首页
- 路径:/
- 类型:home
- 主要板块:全局搜索、最近访问、我的收藏、自定义、资源目录
- 当前用户已隐藏分区:最近访问
- 本页相关能力:可调整首页分区显示、可操作收藏区、可操作自定义区、可搜索平台资源入口
使用规则:
- 用户要求显示/隐藏「最近访问」「我的收藏」或「自定义」时,调用 set_user_platform_section_visibility
- 用户要求置顶/取消置顶首页某张卡片时,调用 set_user_home_card_pin
- 用户询问具体系统/工单/文档入口时,调用 search_portal_resources,不要假装已看到首页全部卡片
- 页面信息不足以下结论时仍应查询工具
这里最重要的是安全边界:页面上下文是前端上报的,不能信。 我们的做法是前端只上报路径和少量用户状态,页面类型、板块、能力全部以服务端路由表为准:
/** 服务端校验并清洗前端上报的页面上下文,防 prompt 注入 */
export function sanitizeAssistantPageContext(raw: unknown) {
if (!raw || typeof raw !== "object") return undefined;
const obj = raw as Record<string, unknown>;
if (typeof obj.path !== "string") return undefined;
const path = obj.path
.replace(/[\r\n\0]/g, "")
.trim()
.slice(0, 500);
if (!path.startsWith("/")) return undefined;
// 以服务端路由表为准,不信任前端伪造的 type/label/sections/capabilities
const result = applyRouteConfig(path, { path });
if (result.type === "home" && Array.isArray(obj.hiddenSections)) {
result.hiddenSections =
sanitizeStringArray(obj.hiddenSections, ALLOWED_HOME_SECTIONS, 8, 32) ??
[];
}
return result;
}
用户状态类字段(比如隐藏了哪些分区)只接受白名单内的值,并限制条数和长度。这样即使有人伪造请求,也只能在几个固定取值里选,没法往系统提示词里塞任意文本。
另外,页面上下文只用来理解意图,不用来下结论。“不要假装已看到首页全部卡片”“页面信息不足时仍应查询工具”这两条规则,是为了防止模型拿页面描述当成数据来源。
八、写完要让页面跟着变
工具在服务端改了数据库,但用户眼前的页面还是旧的。如果用户说完“置顶公告”,页面上公告卡片纹丝不动,就会以为没成功。
我们的做法是在前端收到助手回复后,检查这条消息里执行成功的工具,按工具名让对应的查询缓存失效:
const toolParts =
message.parts?.filter(part => {
const toolPart = part as { type?: string; state?: string };
return (
typeof toolPart.type === "string" &&
toolPart.type.startsWith("tool-") &&
toolPart.state === "output-available"
);
}) ?? [];
const hasTool = (name: string) =>
toolParts.some(part => (part as { type?: string }).type === `tool-${name}`);
if (hasTool("set_user_platform_section_visibility")) {
void utils.preference.get.invalidate();
void utils.assistant.getSmartQuickPrompt.invalidate();
}
if (hasTool("set_user_home_card_pin")) {
void utils.preference.get.invalidate();
}
if (hasTool("manage_user_favorites")) {
void utils.favorite.list.invalidate();
void utils.directory.getTreeByView.invalidate();
void utils.assistant.getSmartQuickPrompt.invalidate();
}
这样写操作的结果和页面上手动点按钮的结果走同一条数据链路,不需要给助手单独做一套“推送 UI 变更”的机制。
还有一个小细节:回复下方的“来源”标签里,这几个写工具是被跳过的。来源标签是给“这个答案依据了什么”用的,置顶、收藏是动作而不是依据,显示成来源反而会误导。
九、改名对 Agent 来说也是破坏性变更
有一次我们把首页的“研发平台”分区改名为“技术平台”。对普通页面来说,这只是改一个标题;对助手来说,要改的地方有一串:
- 工具 description 里的示例。 还写着“研发平台”,模型会继续用旧名称和用户沟通。
- 页面上下文的使用规则。 同上。
- 名称解析的旧称映射。 用户习惯了说“研发平台”,要把旧称映射到新名,否则工具会返回“找不到”。
- 导航匹配逻辑。 原来按名称包含“研发平台”匹配分区,要改成同时兼容新旧名称:
case "tech":
return /技术平台|研发平台|开发工具/.test(name);
而且这不是第一次改名:之前从“开发工具”改成“研发平台”时已经留过一条旧称映射,这次要把两代旧称都指向新名。
这件事给我们的教训是:凡是出现在工具描述、系统提示词、名称映射里的业务名词,都是助手的接口。 改名前最好全局搜一遍,改名时同时保留旧称映射,给用户一个过渡期。
十、空态推荐:把行为数据变成一句可执行的话
写工具上线后,一个自然的问题是:用户怎么知道助手能干这些?
我们在助手的空态(新会话还没提问时)和首页的“快捷提问”卡片里,放了一条基于用户行为生成的推荐指令,点一下就直接发给助手执行。规则只有三条,按优先级依次尝试,每次最多给一条:
| 推荐 | 触发条件 | 生成的指令 |
|---|---|---|
| 添加收藏 | 近 7 天点击某资源 ≥ 3 次,且尚未收藏 | 帮我将【某系统】添加到收藏 |
| 移除收藏 | 收藏超过 14 天,近 30 天一次都没点过 | 帮我把【某系统】从收藏里去掉 |
| 隐藏分区 | “最近访问”近 30 天没用过,但更早之前用过 | 帮我隐藏「最近访问」分区 |
export async function resolveSmartQuickPrompt(db, userId, viewer) {
return (
(await tryAddFavorite(db, userId, viewer)) ??
(await tryRemoveFavorite(db, userId, viewer)) ??
(await tryHideRecent(db, userId))
);
}
几个细节:
- 推荐的就是写工具能执行的话。 指令文本就是一句自然语言,发出去后走的是和用户手动输入完全一样的工具链路,不需要另做一套“推荐执行”逻辑。
- 推荐理由要能核对。 每条推荐都带一个
reason(比如“近 7 天访问 5 次”),鼠标悬停可见,用户知道为什么推荐。 - 避免自相矛盾的推荐。 “最近访问”默认是隐藏的,用户刚打开时近期点击必然为 0。如果只看“近 30 天没用过”,会出现“用户刚打开,马上被建议关掉”的情况。所以条件是“以前用过、现在不用了”。
- 执行完就失效。 前面那段缓存失效代码里,收藏和分区类工具执行后都会让推荐重新计算,避免用户刚收藏完,推荐还在建议收藏同一个系统。
十一、总结
把写操作交给 AI 助手,模型侧的改动很少,工作量几乎都在工具设计上。我们这一轮落下来的清单是:
边界:只改自己的、可逆的、立刻可见的
身份:userId 从会话来,不从参数来
描述:description 写业务规则,rules 写何时调、何时不调、调完怎么说
解析:名称归一化 + 别名 + 旧称,匹配不唯一就返回候选,绝不猜
幂等:先读后写,没变化返回 unchanged,并如实告诉用户
权限:工具可见性、工具执行、普通接口、候选提示四处都要管
上下文:前端只报路径和白名单状态,其余以服务端路由表为准
刷新:按执行成功的工具名让对应查询失效
改名:业务名词就是助手的接口,改名要保留旧称映射
推荐:用行为数据生成可直接执行的一句话
一句话概括:
能调用工具只是开始,难的是让它改得对、改得少、说得实。
相关阅读
- MCP 权限怎么分级?只读、可操作和高风险动作 — 高风险写操作的分级与确认
- MCP 爆炸之后,Agent 怎么从一堆工具里选对那一个 — 工具多了以后的选择与优先级
- Agent 刚才干了什么:审计、调用日志与敏感数据豁免 — 工具调用日志怎么设计
- 提示词安全攻防全解析:越狱、注入与信息泄露 — 页面上下文为什么不能信任前端