跳至正文
来两杯美式
返回

让 AI 助手直接改用户设置:写操作工具的工程细节(附核心代码)

By 来两杯美式
发布于

本文由项目沉淀整理而来,已去除具体单位、系统名称、内部链接和业务数据。代码保留了核心逻辑,做了少量简化。

AI 助手做到一定阶段,用户就不满足于“问”了,会开始让它“做”:

帮我把公告置顶。 把这个系统加到收藏。 最近访问那块用不上,关了吧。

从只读查询走向写操作,模型侧几乎不用改,大模型本来就会调用工具。真正要花心思的是工具本身:用户说的名字怎么对上数据库里的 ID?重复操作要不要再写一次?普通用户能不能碰管理员的东西?写完之后页面怎么刷新?模型怎么知道当前页面上有什么?

我们在内部技术门户的助手里陆续上线了一组个人设置类的写工具:首页分区显隐、首页卡片置顶、收藏管理、自定义入口管理。这篇把过程中踩过、补过的工程细节整理出来。

一、先划清边界:什么样的写操作适合交给助手

不是所有写操作都适合让助手做。我们给第一批写工具定了三个条件:

  1. 只影响用户自己。 置顶、收藏、分区显隐,改的都是当前用户的个人偏好,不影响别人。
  2. 可逆。 置顶了可以取消,收藏了可以移除,没有“删了就回不来”的东西。
  3. 结果在页面上立刻可见。 用户能马上看到改没改对,不对再说一句就能改回来。

满足这三条,就可以让助手直接执行,不需要二次确认弹窗。涉及他人数据、不可逆或高风险的动作,应该走另一套更重的确认和审批机制(可以参考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 尽量保留用户原话中的卡片名;" +
    "如果工具返回未找到并给出候选,向用户确认后再调用。执行成功后简洁告知结果," +
    "若同栏原有置顶被替换,一并说明。",
}

几个写法上的经验:

四、用户口语到 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}」当前未收藏,无需取消`,
  };
}

这里有两个设计选择:

数据库层面 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 来说也是破坏性变更

有一次我们把首页的“研发平台”分区改名为“技术平台”。对普通页面来说,这只是改一个标题;对助手来说,要改的地方有一串:

  1. 工具 description 里的示例。 还写着“研发平台”,模型会继续用旧名称和用户沟通。
  2. 页面上下文的使用规则。 同上。
  3. 名称解析的旧称映射。 用户习惯了说“研发平台”,要把旧称映射到新名,否则工具会返回“找不到”。
  4. 导航匹配逻辑。 原来按名称包含“研发平台”匹配分区,要改成同时兼容新旧名称:
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))
  );
}

几个细节:

十一、总结

把写操作交给 AI 助手,模型侧的改动很少,工作量几乎都在工具设计上。我们这一轮落下来的清单是:

边界:只改自己的、可逆的、立刻可见的
身份:userId 从会话来,不从参数来
描述:description 写业务规则,rules 写何时调、何时不调、调完怎么说
解析:名称归一化 + 别名 + 旧称,匹配不唯一就返回候选,绝不猜
幂等:先读后写,没变化返回 unchanged,并如实告诉用户
权限:工具可见性、工具执行、普通接口、候选提示四处都要管
上下文:前端只报路径和白名单状态,其余以服务端路由表为准
刷新:按执行成功的工具名让对应查询失效
改名:业务名词就是助手的接口,改名要保留旧称映射
推荐:用行为数据生成可直接执行的一句话

一句话概括:

能调用工具只是开始,难的是让它改得对、改得少、说得实。

相关阅读


分享这篇文章:
通过邮件分享这篇文章✓ 链接已复制
›查看系列全部文章
  1. 01.把存量系统交给 Agent,先想清楚 API、Skill、MCP 各是什么
  2. 02.存量系统 Agent 化,物理上到底怎么「接」
  3. 03.单点跑通之后,怎么把 Agent 能力沉淀成全公司可复用
  4. 04.DeepSeek Harness:把 Agent 宿主变成可组合的基础设施,企业能拿它做什么
  5. 05.一个系统,两种访客:给浏览器和 Agent 设计同一套身份体系
  6. 06.定时是 Agent 平台的另一半:Trigger/Action Registry 与调度原语分层
  7. 07.Agent 刚才干了什么:审计、调用日志与敏感数据豁免
  8. 08.提示词安全攻防全解析:越狱、注入与信息泄露
  9. 09.给生产 Agent 做体检(一):只用对话文本的黑盒诊断工作流
  10. 10.给生产 Agent 做体检(二):诊断之前,先定义人口——数据质检与风险筛选
  11. 11.给生产 Agent 做体检(三):让 LLM 稳定输出结构化诊断——严格 JSON Schema 实践
  12. 12.给生产 Agent 做体检(四):LLM 输出不完美怎么办——归一化、一次修复与失败隔离
  13. 13.给生产 Agent 做体检(五):不要消息队列——基于数据库的任务编排与可靠性
  14. 14.给生产 Agent 做体检(六):从诊断明细到 PM 能用的报告——评估结果的产品化
  15. 15.AI Agent:从工具到同事,中间隔着一层「自主性」
  16. 16.Agent 评测工程化(一):为什么不能只看平均分
  17. 17.Agent 评测工程化(二):Rubric 不是 Prompt,而是可执行的质量协议
  18. 18.Agent 评测工程化(三):从低分样本到问题簇
  19. 19.Agent 评测工程化(四):红线、一票否决与 1-5 分
  20. 20.Agent 评测工程化(五):让 LLM-as-judge 稳定输出结构化结果
  21. 21.Agent 评测工程化(六):评测前,先把 Agent 输出拆开
  22. 22.Agent 评测工程化(七):从评分报表到 PM 决策工作台
  23. 23.Agent 评测工程化(八):不用消息队列,也能跑长任务
  24. 24.Agent 评测工程化(九):把一次性评测变成持续优化体系
  25. 25.让 AI 助手直接改用户设置:写操作工具的工程细节(附核心代码)

上一篇
RAG 回答清单问题总漏项?topK 截断与同文档切片补全(附核心代码)
下一篇
闭集检索评测与「刻意不做」清单