数据截至 (上游 commit 136cd875b154)
系统提示与上下文装配(JSX→Markdown)
30 秒导读: nao 的系统提示不是一坨写死的字符串,而是一棵用 TSX 组件写的树(
<SystemPrompt>、<NaoContextStructure>…),交给一个自制的renderToMarkdown渲染成 Markdown 文本再喂给模型。这样提示可以像写 UI 一样条件拼装(接了 BigQuery 就多一段 BigQuery 方言规则),还能被每个项目用一个文件整段覆盖或用{{nao_prompt}}占位内嵌。本章讲这个不显然的巧妙处。
本章聚焦"提示怎么拼出来";Agent 主循环怎么把这段提示流式喂给模型见 02-agent-loop.md,工具本身见 03-tools.md,上下文即文件系统的布局见 01-context-filesystem.md。
1. 这是什么(零基础也能懂)
一句话定义: nao 用写 React 组件的方式写系统提示,再把组件树渲染成纯 Markdown 字符串。
为什么不直接写字符串? 想象一个 300 行的系统提示,里面有一堆"如果……就加一段"的规则:
- 如果连的是 BigQuery,要加"用反引号引标识符、用
SAFE_DIVIDE防除零"。 - 如果连的是 SQL Server,要加"用
TOP N不用LIMIT"。 - 如果有用户记忆,要把记忆按优先级、按 token 预算裁出来拼进去。
- 如果是从别的对话 fork 出来的,要把选中的原文段落附上。
用字符串拼接写这些条件分支,很快会变成一坨到处 if (...) prompt += '...' 的意大利面。nao 的做法是:把每一段提示做成一个组件,用 JSX 的 {condition && <...>} 表达"要不要这段",最后一次性渲染成文本。
一句话直觉: 就像用 React 拼一个网页——只不过"渲染目标"不是 DOM,而是一段 Markdown 文本。组件树长什么样,提示就长什么样。
用起来什么样(它渲染出的东西): 一段真实的 <SystemPrompt> 组件,渲染后大致是这样的 Markdown:
# Instructions
You are nao, an expert AI data analyst ...
Today's date is **Monday, July 14, 2026**.
## How nao Works
- All the context available to you is stored as files in the project folder.
- ...
## Persona
- **Efficient & Proactive**: Value the user's time. ...
## SQL Query Rules
- ...
- **BigQuery dialect:** Use backtick-quoted identifiers ... ← 只有连了 BigQuery 才出现
## User Rules
<RULES.md 的内容>
## Memory
### Global User Rules
- Always show revenue in EUR.
本节不涉及底层。记住一件事就好:提示 = 组件树渲染的结果,而组件树是可以按当前连接、记忆、渠道条件裁剪的。
2. 顶层全景(它大概怎么转)
入口: 一次对话开始时,AgentManager._buildDefaultSystemPrompt 负责把提示装配出来(apps/backend/src/services/agent.ts:598)。它调用三层:先渲染核心 <SystemPrompt>,再按需套上"渠道外壳"和"fork 上下文外壳"。
怎么读下面这张图: 从上往下是"数据从哪来 → 拼成组件树 → 渲染成字符串",左边是喂进去的上下文源,右边是三个可选的包裹层。
采集上下文(都来自项目文件夹 / DB)
┌─────────────────────────────────────────┐
│ memories ← services/memory.ts │
│ userRules ← RULES.md (agents/user-rules) │
│ connections← databases/ 目录扫描 │
│ skills ← agent/skills/ (services/skill)│
│ timezone / testMode / forkMetadata │
└───────────────────┬───────────────────────┘
│ 作为 props 传入
▼
┌──────────────────────────┐
│ <SystemPrompt {...} /> │ 核心提示(TSX 组件树)
└────────────┬─────────────┘
│ renderToMarkdown()
▼ basePrompt(Markdown 字符串)
┌──────────────────┴───────────────────┐
│ 有 provider(Slack/WhatsApp…)? │
│ └→ <MessagingProviderSystemPrompt> │ 套渠道回复规范
│ 有 forkMetadata? │
│ └→ <ChatForkContextPrompt> │ 套"选中原文"上下文
└──────────────────┬───────────────────┘
▼
最终系统提示字符串
部件一句话职责:
| 部件 | 干什么 | 在哪 |
|---|---|---|
renderToMarkdown | 递归走组件树,把 React 节点转成 Markdown 字符串 | lib/markdown/render-to-markdown.ts:15 |
Block/Title/List… | 一组 Markdown 原语组件(段落分隔、标题、列表) | lib/markdown/components.tsx |
SystemPrompt | 核心提示:persona、SQL 规则、引用规则 + 注入的上下文 | components/ai/system-prompt.tsx:46 |
NaoContextStructure | 讲"项目上下文在磁盘怎么摆",被多个提示复用 | components/ai/nao-context-structure.tsx:4 |
MessagingProviderSystemPrompt | 按渠道(WhatsApp/其它)追加回复流程规范 | components/ai/messaging-provider-system-prompt.tsx:6 |
ChatForkContextPrompt | fork 对话时把"用户选中的原文"作为焦点附上 | components/ai/chat-fork-context-prompt.tsx |
getSystemPromptOverride | 找项目自带的提示覆盖文件 | agents/system-prompts.ts:27 |
_buildSystemPrompt | 装配入口:先看覆盖,否则走默认 | services/agent.ts:588 |
主线走一遍(高层): 一次流式请求进来 → _buildModelMessages 拿到系统提示(services/agent.ts:571)→ 若 create() 时传了完全成形的 systemPrompt 就直接用(见 §3.A 末),否则 _buildSystemPrompt 判断有没有项目覆盖 → 没有就 _buildDefaultSystemPrompt 采集上下文、渲染组件树 → 得到字符串,作为 role: 'system' 的消息拼在最前面。
3. 核心原理(逐个机制)
3.A 覆盖与占位注入:项目怎么改写提示
它要解决的小问题: 默认提示对大多数人够用,但有的团队想彻底换掉它(比如换语气、换语言、加公司专属规范),又不想改 nao 源码。nao 让每个项目在 agent/prompts/ 放一个 Markdown 文件就能做到。
找文件的顺序: getSystemPromptOverride 按 provider 挑候选文件——先找 <provider>.md(如 slack.md),没有再找通用的 system.md(agents/system-prompts.ts:20-25)。这让"某个渠道单独定制"成为可能。
两种覆盖模式: 关键在 _buildSystemPrompt 的三分支逻辑(services/agent.ts:588-596):
// 示意,非源码:精简自 services/agent.ts:502
const override = getSystemPromptOverride(projectFolder, provider);
if (override && !hasNaoPromptPlaceholder(override)) {
return override; // ① 整段替换:完全用你的文件
}
const defaultPrompt = await buildDefaultSystemPrompt(...);
return override
? injectNaoPrompt(override, defaultPrompt) // ② 占位内嵌:把默认塞进你的外壳
: defaultPrompt; // ③ 没覆盖:用默认
三种结果一张表看清:
| 项目文件夹里 | agent/prompts/*.md 内容 | 最终系统提示 |
|---|---|---|
| 没有该文件 | —— | 默认提示(§3.C 装配的那份) |
有,且不含 {{nao_prompt}} | 你的全文 | 整段替换成你的文件 |
有,且含 {{nao_prompt}} | 你的外壳 + 占位符 | 把默认提示注入到占位符处 |
占位机制: hasNaoPromptPlaceholder 用正则 \{\{\s*nao_prompt\s*\}\} 检测占位符,injectNaoPrompt 用同一正则全局替换成默认提示(agents/system-prompts.ts:10-18)。于是你可以写"公司抬头 + {{nao_prompt}} + 公司补充规则",既保留 nao 的核心指令又包一层自己的壳。
安全:防目录逃逸。 覆盖文件路径来自项目配置,读之前必须确认它没指到项目文件夹外(避免符号链接把 /etc/passwd 之类读进提示)。readPromptFile 先 realpathSync 解析真实路径(把符号链接展开),再用 isWithinDirectory 校验真实路径确实落在项目真实路径之内,否则拒读并打错误日志(agents/system-prompts.ts:37-55):
// 示意,非源码:精简自 agents/system-prompts.ts:44
const realFilePath = realpathSync(filePath); // 展开软链,拿真身
if (!isWithinDirectory(realpathSync(projectFolder), realFilePath)) {
console.error(`Refusing to read system prompt override outside the project folder`);
return undefined; // 逃出项目 → 拒读
}
isWithinDirectory 的判断很朴素但可靠:算 relative(dir, target),若结果为空串、或不以 .. 开头、且不是绝对路径,就算"在里面"(agents/system-prompts.ts:57-60)。
还有一层更高优先级的覆盖(点到为止): AgentManager 构造时可接收一个完全成形的 systemPrompt 字符串(create() 的 options.systemPrompt),在 _buildModelMessages 里用 this._systemPromptOverride ?? (await this._buildSystemPrompt(...)) 直接短路掉整套装配(services/agent.ts:571、:201、:321)。它跳过默认指令、用户规则、记忆和连接——专给"这些上下文本身是任务对象"的运行用,比如上下文审计(context-recommendations)。这和上面文件级覆盖是两回事:一个是"运行时传入的整段提示",一个是"项目磁盘上的覆盖文件"。
3.B JSX 即提示:组件树怎么变成 Markdown
它要解决的小问题: 怎么把一棵 React 组件树变成 Markdown 字符串,而不经过浏览器、不经过 ReactDOM?
思路: nao 写了一个自制渲染器 renderToMarkdown,自己递归走 React 元素树。tsconfig 里 "jsx": "react-jsx"(apps/backend/tsconfig.json:13)让 TSX 语法编译成标准的 React 元素对象,但这些对象从不交给 ReactDOM——而是交给这个字符串渲染器。
渲染器怎么走树(lib/markdown/render-to-markdown.ts:15):
- 遇到字符串/数字 → 直接转成文本。
- 遇到数组 → 过滤掉不可渲染的(null/boolean),逐个渲染,用
separator拼接。 - 遇到函数组件(
typeof el.type === 'function',render-to-markdown.ts:37)→ 直接调用该函数拿到它返回的子树,继续递归。这一步是关键:组件不是被 React 挂载,而是被当普通函数求值。 - 遇到宿主元素(
Block渲染出的<div>)→ 从它的data-separator/data-prefix/data-indent属性上读排版元信息,再据此拼接子节点(render-to-markdown.ts:42-62)。
排版元信息藏在 props 里。 Markdown 原语组件都建在 Block 之上,而 Block 把"用什么分隔子块、要不要缩进、要不要前缀"编码成 <div> 的 data-* 属性(lib/markdown/components.tsx:4-20):
// 示意,非源码:节选自 lib/markdown/components.tsx:4
function Block({ children, separator = '\n\n', prefix = '', indent }) {
return <div data-separator={separator} data-indent={indent} data-prefix={prefix}>{children}</div>;
}
于是各原语组件只是 Block 的花样组合:Title = # 重复 level 次 + 空格(components.tsx:22);List 给每个 ListItem 前面加 - 或序号、子列表自动多缩进一层(components.tsx:74-97);CodeBlock 用 ``` 包起来(components.tsx:36)。默认分隔符是 \n\n,所以块与块之间天然空一行——这正是 Markdown 段落的规矩。
妙处:条件即分支。 因为提示是 JSX,"要不要某段"就是一个普通的 {condition && <...>}。SystemPrompt 把连接类型交给 getDialectToolCallRules / getDialectSqlQueryRules(components/ai/system-prompt.tsx:62-63)算出方言规则,再在对应规则段条件展开(:118、:204):
// 示意,非源码:节选自 components/ai/system-prompt.tsx:141
{hasBigQuery && (
<>
<ListItem><Bold>BigQuery dialect:</Bold> Use backtick-quoted identifiers ...</ListItem>
<ListItem>Use SAFE_DIVIDE for division to avoid division-by-zero errors.</ListItem>
</>
)}
没连 BigQuery,这两条就根本不出现在提示里——省 token,也避免让模型看无关方言规则。
外层是"装饰器"叠加。 _buildDefaultSystemPrompt 把三层组件像洋葱一样套(services/agent.ts:607-628):核心 SystemPrompt 渲染出 basePrompt → 有 provider 就把它塞进 MessagingProviderSystemPrompt 再渲染 → 有 fork 就再塞进 ChatForkContextPrompt。每层都接收上一层的字符串当 basePrompt,在后面追加自己那段。