Refly skill-template 详解:user-prompt.md 模板如何为工作流节点组装 LLM 用户提示词
在 Refly 这类「vibe workflow(自然语言驱动的工作流)」平台中,工作流的每个节点本质上是一次受限的 Agent 单轮任务执行。节点 Agent 收到的用户提示词(user prompt)不是简单一句问题,而是由「上游上下文 + 文件元数据 + 上游结果元数据 + 视觉能力警告 + 用户问题」拼装而成的结构化载荷。本文以 user-prompt.md 这个 Handlebars 模板为骨架,结合 buildUserPrompt 渲染函数、ContextBlock 数据结构 和 Agent 调用链,完整拆解该模板的设计意图、渲染逻辑与数据流,帮助你在开发类似 Agent 系统时理解「如何用元数据替代全量内容」这一上下文经济性设计。
一、user-prompt.md 在 skill-template 包中的位置
packages/skill-template 是 Refly 技能执行引擎所在的工作区包,其 src/prompts 目录采用「模板文件 + 构建函数」的成对组织方式:
packages/skill-template/src/prompts/
├── templates/
│ ├── partials/
│ │ └── ptc-mode.md # Handlebars 局部模板(PTC SDK 工具模式)
│ ├── copilot-agent-system.md # 画布级 Copilot Agent 系统提示词
│ ├── node-agent-system.md # 节点级 Node Agent 系统提示词
│ └── user-prompt.md # 本文主角:用户提示词模板
├── prompt-template.ts # 模板加载/渲染基础设施
├── user-prompt.ts # buildUserPrompt()
├── node-agent.ts # buildNodeAgentSystemPrompt()
├── copilot-agent.ts
├── node-agent.spec.ts # 系统提示词的单测
└── fixtures/ # 单测用的 SDK 文档夹具
模板的加载由 PromptTemplate 类 统一负责,它封装了 Handlebars,提供两个核心静态/实例方法:
PromptTemplate.load(templateName):先注册templates/partials/目录下所有.md文件为 Handlebars partial(以文件名去扩展名为 partial 名,例如 ptc-mode.md),再读取templates/<name>并编译。注册使用了静态标志位,保证只执行一次;render(data):用传入的数据对象渲染出最终字符串。
buildUserPrompt 在模块顶层执行 PromptTemplate.load('user-prompt.md'),也就是说模板在模块被导入时就从磁盘读取并编译,之后每次调用 render 都复用该编译产物。
二、模板全文逐段解读
user-prompt.md 全文仅 17 行,每一行都承担明确职责。完整原文如下:
User provided following context, please use them wisely to understand the task and solve the problem:
{{#if hasResultsMeta}}
## Previous Results
Context contains `resultsMeta` from upstream nodes. See system prompt for access strategy.
Remember: summary is unreliable — use `read_agent_result` for full content when needed.
{{/if}}
```json
{{{contextJson}}}
{{#if showVisionWarning}} Note: The context contains image files, but the current model does NOT have vision capability. You cannot see the image content. To process images, use execute_code with Python image libraries (e.g., PIL, opencv). {{/if}}
Question: {{query}}
逐段说明:
**1. 引导句(第 1 行)**:固定声明「用户提供了如下上下文,请明智地使用它们来理解任务并解决问题」。这句英文指令是模板的唯一无条件内容,负责给 LLM 定调——后续 JSON 不是噪音,而是任务素材。
**2. `hasResultsMeta` 条件块(第 3–7 行)**:`{{#if hasResultsMeta}}...{{/if}}` 是 Handlebars 的条件语法。仅当上下文里存在上游节点的执行结果元数据(`resultsMeta` 非空)时,才插入「## Previous Results」小节。该小节包含两条行为约束:
- 「See system prompt for access strategy」——访问策略被有意放在系统提示词里维护,用户提示词只做引用,避免两处规则重复演化;对应 [node-agent-system.md](https://link.gitcode.com/i/c942ad59f26d40990d6abe21491266d1) 中「Context (system-injected)」一节对 `read_agent_result` / `read_tool_result` 的完整说明;
- 「summary is unreliable — use `read_agent_result` for full content when needed」——明确提醒摘要字段不可靠,需要全量内容时必须走 `read_agent_result` 工具按 `resultId` 拉取。这条提醒的必要性来自摘要的生成机制(见第四节)。
**3. 上下文 JSON 块(第 9–11 行)**:核心载荷是 `{{{contextJson}}}`,外层包了一个 ` ```json ` 围栏。注意这里使用的是 Handlebars 三花括号(triple-stash)——内容**不做 HTML 转义**。这一点是刻意设计:JSON 中经常出现 `<`、`>`、`&` 等字符,若使用双花括号 `{{contextJson}}` 会被转义成 `<` 之类,破坏 JSON 的可解析性。
**4. `showVisionWarning` 条件块(第 13–15 行)**:当「上下文包含图片文件」且「当前模型没有视觉能力」同时成立时,注入一条粗体警告,明确告知模型「你看不见图片内容」,并给出补救路径:用 `execute_code` 调用 Python 图像处理库(PIL、opencv)。该分支的判定链见第五节。
**5. 问题行(第 17 行)**:以 `Question: {{query}}` 收尾。将用户问题放在最末尾,符合「指令后置」的常见做法——模型读完全部上下文后再面对真正要回答的问题。
渲染时传入的四个数据变量汇总如下:
| 变量 | 类型 | 来源 | 作用 |
|------|------|------|------|
| `hasResultsMeta` | `boolean` | `!!context.resultsMeta?.length` | 控制 Previous Results 小节是否出现 |
| `contextJson` | `string` | `JSON.stringify(context, null, 2)` | 格式化后的 ContextBlock 全量 JSON |
| `showVisionWarning` | `boolean` | 图片存在且模型无视觉能力 | 控制视觉能力警告是否出现 |
| `query` | `string` | 经过预处理优化后的用户问题 | 实际要回答的问题 |
## 三、渲染入口:buildUserPrompt 的完整逻辑
[buildUserPrompt](https://link.gitcode.com/i/e3283557c6421913e30b3ab4722e1efa#L10-L35) 是模板的唯一调用方,签名与行为如下:
```typescript
interface BuildUserPromptOptions {
hasVisionCapability?: boolean;
}
const template = PromptTemplate.load('user-prompt.md');
export const buildUserPrompt = (
query: string,
context: ContextBlock,
options?: BuildUserPromptOptions,
) => {
// If no context, return query directly
if (!context || (!context.files?.length && !context.resultsMeta?.length)) {
return query;
}
const hasResultsMeta = !!context.resultsMeta?.length;
const contextJson = JSON.stringify(context, null, 2);
// Check if context has image files but model doesn't have vision capability
const hasImageFiles = context.files?.some((f) => f.type?.startsWith('image/'));
const hasVision = options?.hasVisionCapability ?? false;
const showVisionWarning = hasImageFiles && !hasVision;
return template.render({
hasResultsMeta,
contextJson,
showVisionWarning,
query,
});
};
四个关键设计点:
- 无上下文时短路返回原始 query。当
context为空、或既无files也无resultsMeta时,函数直接返回query字符串,完全绕开模板。这意味着模板的全部「仪式感」(引导句、JSON 块)只在确有上下文时才存在,避免给模型增加无意义的 token 负担。 contextJson使用两空格缩进的格式化 JSON(JSON.stringify(context, null, 2)),牺牲少量 token 换取可读性——对 LLM 而言,格式化 JSON 的字段边界比紧凑 JSON 更容易定位。- 图片判定基于 MIME 前缀:
context.files中任一文件的type以image/开头即认为存在图片文件。 - 视觉能力默认按「无」处理:
options?.hasVisionCapability ?? false,即调用方不显式声明视觉能力时一律保守判定为不可见图片,从而触发警告。宁可误报警告(模型会被引导去用代码处理图片),也不冒「模型以为看见了实际看不见的图」的风险。
四、contextJson 里装了什么:ContextBlock 的元数据设计
context 参数的类型是 ContextBlock,它是模板 JSON 块的完整数据源:
export interface ContextBlock {
files: ContextFileMeta[];
/** Metadata-only results for on-demand reading via read_agent_result tool */
resultsMeta?: AgentResultMeta[];
totalTokens?: number;
/**
* Protected routing table for archived/compressed content references.
* This field is NEVER truncated during context compression.
*/
archivedRefs?: ArchivedRef[];
}
模板中那句「summary is unreliable」并非空话,其依据在 context.ts 的摘要生成实现里:
resultsMeta中的每条 AgentResultMeta 都带一个summary字段,注释标明「max 300 tokens」,而实际摘要由 extractSummaryFromContent 生成,默认上限MAX_SUMMARY_TOKENS = 100;- 摘要的生成策略是:从内容末尾查找一批中英文「结论性关键词」(如「已完成」「总结」「in summary」「in conclusion」等 20 余词,见 SUMMARY_KEYWORDS),命中后从该段落的段落起点截取到结尾;
- 若未命中关键词或截取结果超限,则退化为 truncateFromEnd:取内容尾部约
maxTokens × 3.5个字符,并加上... [TRUNCATED DUE TO LENGTH LIMIT]前缀标记。
也就是说,summary 可能是关键词段落,也可能只是「截了个尾巴」。这正是模板必须写明「summary is unreliable,全量内容请用 read_agent_result 拉取」的原因——模板在提示词层面把「元数据摘要不可信」这一工程事实转化成了对模型的行为约束。
同样地,files 数组装的是 ContextFileMeta(name、fileId、type、summary,以及可选的 variableId / variableName),不含文件正文。node-agent-system.md 的系统提示词中对应写道:files 是「metadata only」,正文需用 read_file(fileId) 按需获取;resultsMeta 也是元数据,contentTokens > 300 或需要细节时用 read_agent_result(resultId) 读取全量。模板(用户侧)与系统提示词(策略侧)在此形成呼应:用户提示词里只塞「目录」,系统提示词教模型「怎么查」。
此外 archivedRefs 字段(见 ArchivedRef)是上下文压缩时的「受保护路由表」,注释明确其永不被截断,让模型能快速定位并取回被归档/压缩的内容——这也随 contextJson 一并注入到提示词中。
五、showVisionWarning 的判定链
警告分支由两个布尔量决定,二者的产生位置分别在 user-prompt.ts 和 agent.ts:
- 模型能力侧:
Agent.commonPreprocess中从modelConfigMap[scene].capabilities.vision读取hasVisionCapability,再作为选项传入buildUserPrompt(optimizedQuery, context, { hasVisionCapability })(见 agent.ts L139-L143)。视觉能力不是写死的,而是随每个节点所选模型的能力声明动态变化; - 上下文侧:
context.files中是否存在type以image/开头的条目; - 两者与运算得到
showVisionWarning = hasImageFiles && !hasVision。
这条警告的工程价值在于消除模型的感知错觉:无视觉能力的模型收到带图片的上下文时,容易假设自己能「看到」图片并基于文件名或摘要臆测内容。模板显式声明「You cannot see the image content」并给出替代工具(execute_code + PIL/opencv),与系统提示词中「Tool Coordination」一节的规则一致(图片文件 → 若上下文可见则直接回答,否则用 execute_code;read_file 工具本身不支持图片,见 node-agent-system.md L84)。
六、调用链:buildUserPrompt 在 Agent 执行循环中的位置
从 Agent.commonPreprocess 可以看到完整装配顺序:
const systemPrompt =
mode === 'copilot_agent'
? buildWorkflowCopilotPrompt({ ... }) // 画布级多轮 Copilot
: buildNodeAgentSystemPrompt({ ptcEnabled, ptcContext, ptcSequential }); // 节点级单轮
const modelConfigScene = getModelSceneFromMode(mode);
const modelInfo = config?.configurable?.modelConfigMap?.[modelConfigScene];
const hasVisionCapability = modelInfo?.capabilities?.vision ?? false;
const userPrompt = buildUserPrompt(optimizedQuery, context, { hasVisionCapability });
const requestMessages = buildFinalRequestMessages({
systemPrompt, userPrompt, chatHistory: usedChatHistory, messages, images, modelInfo,
});
其中 optimizedQuery 和 context(即 ContextBlock)都来自上游预处理(preprocessResult),即模板最终注入的 {{query}} 是经过预处理优化后的问题,而非原始输入。
随后 buildFinalRequestMessages 把 userPrompt 装配进 LangChain 消息序列:
- 空提示词校验:
userPrompt为空直接抛错——注释说明这是 AWS Bedrock Converse API 的硬性要求(human 消息内容不能为空); - 消息排序:
SystemMessage(systemPrompt)→chatHistory→messages→finalUserMessage(userPrompt),用户提示词始终位于序列末尾且「不应被缓存」; - 图片拼接:若传入
images数组,final user message 会变成「text + 多个 image_url」的数组型 content; - 上下文缓存:若模型声明支持
contextCaching,则调用applyContextCaching,采用「系统消息后的全局静态缓存点 + 最近 3 条可缓存消息的动态缓存点」两级策略,以 Bedrock 的cachePoint标记实现。
装配完成的 requestMessages 会作为初始状态进入 LangGraph 状态图(llm 节点 ↔ tools 节点的 ReAct 循环),相关保护机制包括:recursionLimit = 2 × 25 + 1(约 25 轮工具迭代上限)、连续 3 次完全相同的工具调用即判定死循环并强制结束(见 agent.ts L37-L43 与 L466-L504)。模板在此承担的职责到此完成:它决定了这一轮循环的「第 0 条人类消息」长什么样。
作为对照,系统提示词的构建 buildNodeAgentSystemPrompt 有配套单测 node-agent.spec.ts:验证 PTC 关闭时不出现 PTC 段落、开启时正确渲染 toolset 列表与 SDK 文档。从 prompts 目录的文件结构看,user-prompt 模板本身目前没有对应的独立 spec 文件,其渲染行为主要依赖上述短路逻辑与条件分支,这也是该模块后续可以补充测试的明显缺口。
七、渲染示例:三种典型输入下的最终提示词
结合 buildUserPrompt 的分支逻辑,可以推演出模板的实际输出形态:
情形 A:无上下文 —— context.files 与 context.resultsMeta 均为空:
帮我把这批销售数据做一张月度趋势图
(直接返回 query,模板完全未参与。)
情形 B:仅文件上下文、模型有视觉能力 —— 只有 files,无 resultsMeta,hasVisionCapability: true:
User provided following context, please use them wisely to understand the task and solve the problem:
```json
{
"files": [
{
"name": "sales_2025.csv",
"fileId": "file_a1b2c3",
"type": "text/csv",
"summary": "2025 年各月销售数据",
"variableId": "var_1",
"variableName": "sales_data"
}
],
"totalTokens": 182
}
Question: 帮我把这批销售数据做一张月度趋势图
(无 `## Previous Results`,无视觉警告。)
**情形 C:上游结果 + 图片 + 非视觉模型** —— 有 `resultsMeta` 且有 `image/*` 文件,模型无视觉能力:
```text
User provided following context, please use them wisely to understand the task and solve the problem:
## Previous Results
Context contains `resultsMeta` from upstream nodes. See system prompt for access strategy.
Remember: summary is unreliable — use `read_agent_result` for full content when needed.
```json
{
"files": [
{ "name": "chart.png", "fileId": "file_x9y8z7", "type": "image/png", "summary": "上一步生成的柱状图" }
],
"resultsMeta": [
{
"resultId": "res_001",
"title": "抓取网页数据",
"status": "success",
"summary": "... [TRUNCATED DUE TO LENGTH LIMIT] 共抓取 12 个页面,其中 2 个被验证码拦截",
"contentTokens": 2841,
"createdAt": "2026-09-15T02:00:00.000Z",
"toolCallsMeta": [
{ "callId": "call_7f", "toolName": "web_fetch", "status": "success" }
],
"outputFiles": [
{ "name": "data.json", "fileId": "file_j1", "type": "application/json", "summary": "" }
]
}
],
"totalTokens": 421
}
Note: The context contains image files, but the current model does NOT have vision capability. You cannot see the image content. To process images, use execute_code with Python image libraries (e.g., PIL, opencv).
Question: 分析这个柱状图并给出结论
(两个条件块同时命中;`summary` 带截断前缀,恰好演示了「summary 不可信、应按 `resultId` 用 `read_agent_result` 拉全量」的场景。)
## 八、可复制的工程模式与适用边界
从这套模板实现中,可以提炼出几个在 Agent 系统中可直接借鉴的模式:
1. **「元数据入提示词,正文走工具」的上下文经济模型**:`ContextBlock` 只保留 `ContextFileMeta` / `AgentResultMeta`,全量内容交给 `read_file` / `read_agent_result` / `read_tool_result` 按需拉取。模板中的两句提醒(「See system prompt for access strategy」「summary is unreliable」)是把工程约束翻译成模型行为的关键——没有这两句,模型可能把截断摘要当成完整结论;
2. **模板条件块与数据事实联动**:`hasResultsMeta`、`showVisionWarning` 都由真实数据驱动而非静态文案,确保提示词不出现「空小节」或「虚假声明」(例如不存在上游结果时不会出现 Previous Results 标题);
3. **三花括号防转义**:向 LLM 注入 JSON 载荷时务必用 `{{{...}}}`,否则 `<`、`>`、`&` 会被 HTML 转义破坏结构;
4. **可复现的模板基础设施**:新增模板只需在 `templates/` 下放一个 `.md` 文件并 `PromptTemplate.load('xxx.md')`,局部段落放 `partials/` 自动注册([prompt-template.ts L25-L51](https://link.gitcode.com/i/e040b75660bd39260c0cb9f63c8a020a));构建函数保持纯函数形态(输入 query/context/options,输出字符串),便于像 [node-agent.spec.ts](https://link.gitcode.com/i/a50b5ceeed14d165fc0a69b99d1913b4) 那样用字符串断言写单测。
适用边界方面需要注意:该模板是面向 **Node Agent 单轮任务**的英文固定文案,模板内没有 i18n 处理(从源码结构看,语言切换依赖系统提示词中「ALWAYS respond in the user's language」这类约束而非模板本身);`hasVisionCapability` 完全依赖 `modelConfigMap` 中模型能力声明的准确性,声明错误会直接导致警告缺失或误报;而「无上下文即返回裸 query」的短路策略意味着任何针对上下文的提示词改造都不作用于纯问答场景。理解这些前提,再参考 [user-prompt.md](https://link.gitcode.com/i/f74cd027674de3f8355df688d9b96521) 与 [user-prompt.ts](https://link.gitcode.com/i/e3283557c6421913e30b3ab4722e1efa) 的组合,就能在自己的工作流引擎里复现这套「结构化上下文注入 + 按需读取 + 能力感知」的用户提示词装配方案。
创作声明:本文部分内容由AI辅助生成(AIGC),仅供参考



