深入设计 Agent:组件库 AI 助手与提示词工程
上一节的重构让"新增 agent = 一个定义文件 + 注册表一行"。这节就用这个地基认真设计一个有业务价值的 agent——给 ai-chat-ui 自己装一个组件库 AI 助手。重点不在工具调用本身(15章通了),而在提示词怎么设计、工具怎么拿捏颗粒度、agent 的"人格"怎么长出来——这是代理编程里 AI 替代不了你的那部分。
为什么选这个场景
先把 agent 说破:它就是预制提示词 + 一组工具构成的智能体——提示词划定"它是谁、不做什么",工具决定"它能干什么"。这节两样都要设计,而 Mastra 的价值在于:创建 agent 只需要一个定义,Studio 让你可以随时调试它。
三个场景候选里(客服助手/代码审查/组件库助手)选了最后一个,理由很实际:
- 数据是现成且真实的。组件库的文档(
packages/docs/下几十个 md)、组件源码、API 表格全在仓库里,工具可以真实实现——不需要假想一个vectorStore.search()。对比:客服场景的知识库和订单 API 都得编。 - 用户就是你自己。开发组件库的人天天被"这个 prop 怎么传""暗色模式下样式怎么丢了"这类问题找上门——给库装个助手,第一个用户就是课程项目自己, dogfooding 闭环。
- 难度刚好。比天气复杂 enough(要检索、要引用来源、要承认不知道),又不至于一步跳到需要向量库的程度(那是 17-04 RAG 的事)。
需求三句话:用户问组件用法 → 助手查文档给答案带出处;用户贴报错 → 助手定位可能原因;文档里没有的 → 明确说不知道,不编。
提示词设计:一个真实的迭代过程
第4章讲过提示词五大要素,这里是一次完整的应用。instructions 不是一次写好的,是迭代出来的——把三次版本摆出来,比给"完美答案"有教学价值。
v1:角色一句话——太薄
你是 ai-chat-ui 组件库的技术支持助手,回答用户关于组件使用的问题。
text
跑起来的问题很快暴露:用户问"Vue 怎么监听数组变化"(超出库范围)它也认真答;问"InputArea 怎么禁用"它不调工具凭训练记忆瞎编 props;回答格式忽而表格忽而段落。角色一句话只定义了"你是谁",没定义"你不做什么、怎么做"。
v2:加上边界与工具规范
你是 ai-chat-ui 组件库(Vue 3 组件库,包名 @toimc/vue)的技术支持助手。
职责边界:
- 只回答 ai-chat-ui 组件库相关的问题:组件用法、props/events/slots、主题定制、集成问题
- 通用 Vue/TypeScript 问题,礼貌说明超出范围后可简要提示方向,不展开教学
工具使用规范:
- 涉及组件用法、API、配置的问题,必须先调用 search_docs 检索文档,基于检索结果回答
- 回答组件 API 时给出处:组件名 + 文档章节
- 检索结果不含相关信息时,明确说"文档中未找到相关内容",不要凭记忆编造 API
回答风格:简洁、代码示例优先、中文。
text
边界和防幻觉立住了,但还有个隐蔽问题:输出格式不稳定——有时 props 表格、有时 bullet 列表,用户在聊天界面里看到的密度忽高忽低。而且用户贴一大段报错时,agent 直接开始检索,没有先从报错里提取关键信息。
v3:输出契约 + 复杂输入的处理策略(最终版)
在 v2 基础上补两段:
输出格式约定:
- 回答组件 API:用表格列 props/events(名称/类型/默认值/说明),代码示例用 ```vue 围栏
- 回答排查类问题:先一句结论,再列可能原因(按概率排序),每个原因附验证方法
- 回答控制在 300 字以内,除非用户要求展开
处理用户输入:
- 用户贴报错信息时,先从报错中提取关键要素(组件名/错误类型/涉及 API),再决定检索词
- 用户问题含糊时(如"样式怎么不对"),先反问澄清:哪个组件、什么现象、明暗主题下是否一致
text
三次迭代的规律总结成一句话:v1 定义"是谁",v2 定义"不做什么",v3 定义"输出长什么样"。写 instructions 时按这个顺序自查,比照抄模板有效。
还有一个容易忽略的事实:你其实写了三份提示词。instructions 之外,工具的
description(告诉模型何时该调它)和工具参数 schema 里每个字段的.describe()(告诉模型怎么填),都是模型读的文档。16-03 会看到工具 description 承担路由职责的极致用法。三份"文档"口径必须一致——instructions 说"必须先检索",工具 description 就不能写成"可选时调用"。
工具设计:search_docs 的实现与颗粒度
为什么不上向量检索
直觉上"文档问答=RAG=向量库",这里刻意克制:先用关键词检索实现,理由三条——文档量小(66 篇文档全量读进来不到 1MB,每次执行直接全量读盘,无需索引常驻),关键词在组件名/prop 名这类精确查询上反而准,零外部依赖(不用起 embedding 服务)。17-04 讲 RAG 时会把同一场景升级为语义检索,对比着看就知道什么时候才值得上向量。工程决策的顺序是:先用最简单的能满足需求的方案,把"什么时候需要更重的方案"想清楚。
实现:关键词检索版(16 章时点)
// packages/dev-server/src/tools/search-docs.ts
import { readdirSync, readFileSync } from 'node:fs'
import { join, relative } from 'node:path'
import { createTool } from '@mastra/core/tools'
import { z } from 'zod'
const DOCS_ROOT = join(import.meta.dirname, '../../../../docs')
/** 递归收集 md 文件(跳过 node_modules / dist / .vitepress 产物) */
function collectMdFiles(dir: string): string[] {
const out: string[] = []
for (const entry of readdirSync(dir, { withFileTypes: true })) {
if (entry.name.startsWith('.') || entry.name === 'node_modules' || entry.name === 'dist') continue
const full = join(dir, entry.name)
if (entry.isDirectory()) out.push(...collectMdFiles(full))
else if (entry.name.endsWith('.md')) out.push(full)
}
return out
}
export const searchDocsTool = createTool({
id: 'search_docs',
description:
'检索 ai-chat-ui 组件库文档。适用于:组件用法、props/events/slots 查询、主题定制、集成配置、报错排查。输入组件名或关键词,返回最相关的文档片段与出处。',
inputSchema: z.object({
query: z.string().describe('检索词:组件名(如 InputArea)或关键词(如 暗色模式 主题)'),
component: z.string().optional().describe('限定组件名时填写,提高命中率'),
}),
execute: async ({ query, component }) => {
const files = collectMdFiles(DOCS_ROOT)
const scored: Array<{ source: string; title: string; snippet: string; score: number }> = []
for (const file of files) {
const content = readFileSync(file, 'utf-8')
const lower = content.toLowerCase()
let score = 0
for (const term of (component ? [component, query] : [query])) {
const t = term.toLowerCase()
score += (lower.match(new RegExp(t.replace(/[.*+?^${}()|[\]\\]/g, '\\$&'), 'g')) ?? []).length
}
if (score === 0) continue
// 命中位置附近截 400 字作片段
const idx = lower.indexOf(query.toLowerCase())
const start = Math.max(0, idx - 100)
scored.push({
source: relative(DOCS_ROOT, file),
title: content.match(/^#\s+(.+)$/m)?.[1] ?? file,
snippet: content.slice(start, start + 400),
score,
})
}
return { results: scored.sort((a, b) => b.score - a.score).slice(0, 5) }
},
})
typescript
要点:工具返回结构化数据带 source 字段——这是 instructions 里"给出处"能兑现的前提。工具结果的可信度是设计出来的,不是模型自觉出来的。
时点说明:上面是 16 章当时的关键词版骨架。此后它经历了下文详述的两个补丁(同义词表/中英切分/HINT_NO_HIT),17-04 又置换为"关键词+向量"混合检索——但
inputSchema、返回的results[].source/title/snippet形状一路未变,agent 的 instructions 和前端渲染因此零改动。接口形状的稳定,比内部实现的演进重要得多。
实测暴露的天花板:字面匹配跨不过"说法差异"
关键词版跑起来后,实测很快撞上了天花板——用户问法与文档词汇不一致时,字面匹配直接失明:
- 问"消息组件怎么用":
search_docs调了两次,文档里只有"通知""聊天消息列表""如何创建消息对比组件"——Message 组件的文档没被找出来; - 问"对比消息组件":0 命中。组件明明存在(ComparisonMessage),但文档标题写的是"消息对比组件"——一字之差,正则跨不过去。
排查这个问题的做法很值得抄:把症状原样丢给 AI,让它读 search-docs 的源码自己定位。AI 读完给出了三个硬伤,条条命中要害:
- query 是整串字符串匹配,没有分词——「Message组件」这种无空格混排词整串在任何文档里都不存在;
- 没有同义词、没有中英文归一——"聊天气泡"找不到"气泡","暗色"找不到"dark";
- 打分是裸出现次数,没有 IDF、没有字段权重——标题里出现一次比正文里出现十次更说明问题。
随后打了两个补丁(都能在现在的 search-docs.ts 里看到):
- 同义词表 + 中英边界切分 + 标题加权:
SYNONYMS映射表(聊天气泡↔气泡/消息气泡/bubble、暗色↔深色/夜间/dark、主题↔换肤/皮肤/theme 等约 20 组——清单跟着真实失败案例长,不预先铺满);splitCjkLatin在中英文字符边界插空格(「Message组件」→ message + 组件);标题行合并参与加权,标题命中是最强信号; - HINT_NO_HIT 兜底:0 命中时返回换词建议——先调
list_components看组件清单,或换组件英文名(如 PromptInput)重试。让模型下一轮自己修正检索词,而不是硬编。
但要清醒:这两个补丁是止痛不是治病——说法差异是无穷的,同义词表永远追不完。根治方案是把"文本的字面匹配"换成"语义的向量距离比较":把查询文本和文档都变成向量,比的是语义距离而不是字符是否相同,"聊天气泡"和"气泡"天然就在近处。这也是为什么银行、文档站的那类"权威搜索"文档助手体验普遍差——字面匹配的天花板摆在那。17-04 会做这次引擎置换(LibSQLVector 混合检索),输入输出形状不变,前端零改动。
颗粒度:一个大工具还是几个小工具
设计中要做的取舍:search_docs 一个工具,还是 search_docs + read_component_source + list_components 三个?判断标准不是越多越强,而是模型的选择成本:每个工具都占用决策空间(16-03 讲工具过多导致误调),工具数量够用就好。本节先只做 search_docs;当你观察到 agent 频繁"想看源码但只能看文档片段"时,再加 read_component_source——工具清单跟着真实失败案例长,不预先设计。
落地:一个文件 + 一行注册
动手前有个插曲值得记录。给 AI 发的创建指令只有一句话("在 Mastra 应用中创建一个组件库助手 agent,提示词如下"),它没有直接写代码,而是先调起 brainstorm 头脑风暴拆解需求——结果"作弊"直接翻出了本地早已存在的方案文档,被当场叫停;另一次创建 agent 时,因为装着 claude-mem(5-3 的跨会话记忆),它读到了先前会话里做这事的记忆,径直找到了对应文件。两个细节指向同一个趋势:AI 工具是越用越好用的——项目里沉淀的文档、规则、历史记忆,都会变成它下一次执行的上下文。人要做的反而是判断:它拿到的上下文对不对、要不要拦。
回到正题。用上一节验收过的机制,docs-agent 的落地是机械动作:
// packages/dev-server/src/agents/docs-agent.ts
import type { MastraAgentDefinition } from '@toimc/server/mastra'
import type { MastraEnv } from '../env'
import { resolveModelConfig } from './model-config'
import { searchDocsTool } from '../tools/search-docs'
import { DOCS_AGENT_INSTRUCTIONS } from './instructions'
export const DOCS_AGENT_ID = 'docs-agent'
export function docsAgentDefinition(env: MastraEnv): MastraAgentDefinition {
return {
id: DOCS_AGENT_ID,
name: '组件库助手',
description: 'ai-chat-ui 组件库的技术支持:组件用法、API 查询、问题排查',
model: resolveModelConfig(env),
instructions: DOCS_AGENT_INSTRUCTIONS, // v3 版本
tools: { searchDocsTool },
// 不配 memory:文档问答无跨会话记忆需求,会话内上下文由 thread 天然携带
}
}
typescript
注册表加一行:
// packages/dev-server/src/agents/index.ts
export function buildAgentDefinitions(env: MastraEnv): MastraAgentDefinition[] {
return [chatAgentDefinition(env), docsAgentDefinition(env)]
}
typescript
重启服务,/api/models 出现 docs-agent。用 Studio 调试它:在 Studio 的 playground 里问"InputArea 怎么禁用输入",右侧 trace 里能完整看到——模型决策调用 search_docs(参数 query: 'InputArea 禁用')→ 工具返回带 source 的片段 → 模型基于片段组织出带出处的回答。instructions 改一个字,重新发送,行为差异立刻可见——这就是上一节坚持保留 Studio 的回报:提示词迭代从"改了→部署→猜效果"变成"改了→当场看trace"。
前端接入零成本:useChat 的 model 参数传 docs-agent,消息流里的 tool_call/tool_result 由 12-02 的 ToolCallPanel 自动渲染。
Studio 后端的模型与 key:一个新手坑
在 dev-server 的 .env 里配好 MASTRA_MODEL 系列后启动 Studio,可能仍收到 "Provider not connected" 警告。原因:Studio 自己的 playground 后端需要一个模型来执行你注册的 agent,而它的连接检查只认 OPENAI_API_KEY 这个变量名——与前端 Playground 里用户自配的 provider(/api/providers 运行时注册)完全是两码事。解法很简单(.env.example 里有现成注释):补一行 OPENAI_API_KEY,值与 MASTRA_MODEL_API_KEY 同值即可,实际调用仍走 MASTRA_* 配置。所以 dev-server 的 env 里其实是两组模型配置:给网关/agent 的 MASTRA_* 一套,给 Studio 连接检查的 OPENAI_API_KEY 一行。
调试技巧:把 Studio 的会话链接直接丢给 AI
Studio 的会话数据就落在本地 .temp 的库里。排查 agent 行为不用在面板里来回复制粘贴——直接把 Studio 的会话/trace 链接发给 Claude Code,它用本地工具读日志、看运行记录,自己定位问题。实测一次"检索结果不对"的排查:AI 读完 trace 后直接指认"工具解析文档的路径不对",修完即好。从"盲猜哪里错了"到"指认现场",一轮对话完成——这是 16-01 坚持 Studio 深度集成的直接回报。
前端呈现:从"能看"到"看懂"(精华版)
ToolCallPanel(12-02)解决了"能看":可折叠面板、状态图标、JSON 展示。但拿 docs-agent 实测,普通用户面对的是三个体验缺口。补法都很轻:
缺口一:函数名不是人话。 头部显示 search_docs (calling)——用户不认识。加一层宿主侧展示配置(组件库管结构、宿主管内容,12-05 的老原则):
// playground 侧的展示映射
export const toolDisplay = {
search_docs: {
label: '文档检索',
statusText: (args) => `正在检索「${args.query}」相关文档…`,
},
}
typescript
改造前后:⏳ search_docs (calling) → ⏳ 正在检索「InputArea 禁用」相关文档… → ✓ 文档检索 · 412ms。
缺口二:结论埋在 JSON 里。 默认全量展示工具结果,用户要的答案淹在字段中。两层披露:第一眼给结论卡片,原始 JSON 折叠进"查看原始数据"。用注册表把"工具名→渲染组件"映射起来:
// 文档检索结果 → 文档卡片(标题+出处链接+要点),未注册的工具走 RawResult 兜底
export const toolRenderers = { search_docs: DocsResultCard }
typescript
这个注册表思路推到极致就是生成式UI(18-01 的主题):不再由前端预注册渲染器,而是 AI 在输出里直接声明渲染什么组件。这里的 DocsResultCard 就是它的雏形——先让每个工具的结果有"人话的默认形态"。卡片写三条约定(18 章直接复用):props 只收解析后的对象、卡片只展示不发请求、字段缺失不白屏。
缺口三:出错时用户只见一坨技术细节。 两层结构:人话结论+可行动建议在上("网络连接不稳定,点击重新生成"),技术详情折叠在下(反馈问题时附上)。用户话术和诊断信息都要有,但永远分开展示。
思考链的呈现一条经验带过:流式期间默认折叠、头部实时摘要(前 40 字),完成后切换为"已思考 3.2 秒"可回溯——透明但不喧宾夺主。
小结
- 场景选择:组件库助手——数据现成真实(66 篇文档 <1MB 全量读盘)、dogfooding 闭环、难度梯度刚好(关键词检索起步,17-04 升级语义检索)
- 提示词三份文档:instructions(v1 定义是谁 → v2 定义不做什么 → v3 定义输出长什么样)、工具 description(何时调用)、schema 字段 describe(怎么填)——三者口径必须一致
- 工具设计:search_docs 零依赖关键词检索(结构化结果带 source,出处是设计出来的);颗粒度跟着真实失败案例长,不预先设计
- 检索天花板与补丁:字面匹配跨不过说法差异(问"对比消息组件"找不到"消息对比组件")——AI 读源码定位三硬伤(无分词/无同义词归一/裸词频无权重),补了 SYNONYMS 同义词表 + 中英边界切分 + 标题加权 + HINT_NO_HIT 换词建议;止痛不治病,根治是 17-04 的语义检索(引擎置换、接口形状不变)
- 落地成本:一个定义文件+注册一行;Studio 里改提示词→看 trace→迭代,提示词工程第一次有了"热重载"
- 两个实战坑:Studio 连接检查只认
OPENAI_API_KEY变量名(与 MASTRA_MODEL_API_KEY 同值即可);排查 agent 行为直接把 Studio 会话链接丢给 Claude Code,它读本地日志指认现场 - 前端三缺口:意图预告(函数名→人话)、两层披露(结论卡片+折叠原始数据,注册表是 18-01 生成式UI雏形)、错误两层结构(人话在上、技术折叠在下)
单个 agent 的设计方法有了。下一节把这个模式抽象成通用架构——当 agent 从一个变成一群,配置化注册、工具与记忆的可插拔、以及绕不开的成本问题。
↑