跳到主要内容

系统提示与上下文装配(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:512)。它调用三层:先渲染核心 <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:26
NaoContextStructure讲"项目上下文在磁盘怎么摆",被多个提示复用components/ai/nao-context-structure.tsx:4
MessagingProviderSystemPrompt按渠道(WhatsApp/其它)追加回复流程规范components/ai/messaging-provider-system-prompt.tsx:6
ChatForkContextPromptfork 对话时把"用户选中的原文"作为焦点附上components/ai/chat-fork-context-prompt.tsx
getSystemPromptOverride找项目自带的提示覆盖文件agents/system-prompts.ts:27
_buildSystemPrompt装配入口:先看覆盖,否则走默认services/agent.ts:502

主线走一遍(高层): 一次流式请求进来 → _buildModelMessages 拿到系统提示(services/agent.ts:485)→ 若 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:502-510):

// 示意,非源码:精简自 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 之类读进提示)。readPromptFilerealpathSync 解析真实路径(把符号链接展开),再用 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:485: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 里用连接类型算出四个布尔量,再在 SQL 规则里条件渲染对应方言段(components/ai/system-prompt.tsx:35-38:125-157):

// 示意,非源码:节选自 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:517-527):核心 SystemPrompt 渲染出 basePrompt → 有 provider 就把它塞进 MessagingProviderSystemPrompt 再渲染 → 有 fork 就再塞进 ChatForkContextPrompt。每层都接收上一层的字符串当 basePrompt,在后面追加自己那段。

3.C 注入了什么:装配进提示的上下文

它要解决的小问题: 光有指令不够,提示还得带上"这个用户/项目的具体情况"。_buildDefaultSystemPrompt 在渲染前先并采集四类上下文(services/agent.ts:512-519),作为 props 传给 SystemPrompt

注入项从哪采进提示后长什么样
记忆 memoriesmemoryService.safeGetUserMemories(services/memory.ts:27)## Memory 段,按类别分组列出
用户规则 userRulesRULES.md 原文(agents/user-rules.ts:7)## User Rules 段,整段贴入
连接 connectionsdatabases/ 目录结构(agents/user-rules.ts:27)## Current User Connections 段 + 触发方言规则
技能 skillsskillService.getSkills(services/skill.ts:66)## Skills 段,每个列 name/description/location
timezone / testMode / fork运行参数影响日期显示、是否给 clarification 工具、是否附选中原文

记忆有 token 预算。 不是把所有记忆一股脑塞进去:getMemoriesInTokenRange 按类别优先级(MEMORY_CATEGORIES = global_rule 先于 personal_fact,types/memory.ts:8)逐条累加,超过 MEMORY_TOKEN_LIMIT = 1000 就停(components/ai/system-prompt.tsx:24:234-249)。于是"全局规则"优先挤进有限预算,个人画像其次。记忆从哪来(后台抽取)见下,落库细节本章只点到。

记忆是后台抽出来的。 每次用户发消息后,_scheduleMemoryExtraction 异步触发抽取(services/agent.ts:424:602),MemoryExtractorLLM 把"已有记忆 + 最近对话"喂给一个小模型,让它产出两类结构化输出——user_instructions(指令型,如"永远用 EUR")和 user_profile(画像型,如"用户是增长团队的")——每条还能带 supersedes_id 声明它取代了哪条旧记忆(agents/memory/memory-extractor-llm.ts:30-50types/memory.ts)。抽出的记忆落库,下次对话再由 safeGetUserMemories 读回注入。这是一条"抽取 → 落库 → 注入"的闭环,落库的具体 SQL 见 queries/memoryservices/memory.ts_persistExtractedMemories

连接决定方言。 getConnections 不查数据库,而是读目录名:databases/type=<db>/database=<name>/...,从 type= 前缀解析出连接类型(agents/user-rules.ts:27-44)。这正是 01-context-filesystem.md 讲的"上下文即文件系统"——连提示里塞哪些方言规则,都由磁盘上的文件夹结构决定。

渠道与 fork 的外壳。 MessagingProviderSystemPrompt 给 WhatsApp 加"纯文本、无 markdown、先给结论"的规则,给其它渠道加"计划→静默执行→输出"三段式流程(messaging-provider-system-prompt.tsx:33-131)。ChatForkContextPrompt 在从某段选中文本 fork 出对话时,用 <Quote> 把原文附上并要求模型以它为焦点(chat-fork-context-prompt.tsx)。


4. 深入实现:一次装配的完整走读

跟着 _buildDefaultSystemPrompt 走一遍(services/agent.ts:512):

// 示意,非源码:精简自 services/agent.ts:512
async _buildDefaultSystemPrompt(provider, timezone, chatUrl) {
const memories = await memoryService.safeGetUserMemories(userId, projectId, chatId);
const userRules = getUserRules(projectFolder); // 读 RULES.md
const connections = getConnections(projectFolder); // 扫 databases/
const skills = skillService.getSkills(); // agent/skills/ 缓存

const basePrompt = renderToMarkdown(
SystemPrompt({ memories, userRules, connections, skills, timezone, testMode }),
);

const renderedPrompt = provider
? renderToMarkdown(MessagingProviderSystemPrompt({ basePrompt, provider, chatUrl }))
: basePrompt;

return forkMetadata
? renderToMarkdown(ChatForkContextPrompt({ basePrompt: renderedPrompt, forkMetadata }))
: renderedPrompt;
}

几个值得注意的实现细节:

  • 每层单独 renderToMarkdown 外壳组件接收的 basePrompt已渲染的字符串(不是组件),它们把它当普通子节点插进自己的 <Block>——renderToMarkdown 遇到字符串就原样返回(render-to-markdown.ts:16),所以内层结果不会被二次转义。
  • 技能是带缓存 + 文件监听的。 SkillService 启动时扫 agent/skills/*.md,用 gray-matter 解析每个文件的 frontmatter 取 name/description,并 watch 该目录、变更后 debounce 2s 重载(services/skill.ts:44-113)。提示里只放技能的名字、描述、位置;完整内容要等用户用 / 触发某技能时才 getSkillContent 读全文(services/skill.ts:70)。这让提示保持精简——技能目录是"可按需展开的索引",而非全量正文。
  • 测试模式改行为。 testMode 为真时,SystemPrompt 去掉 clarification 工具那条规则(system-prompt.tsx:83),对应主循环的停止条件也不再等 clarification(services/agent.ts:213-217)。

同款技术的另一处应用: 上下文审计运行(context recommendations)完全复用这套 JSX→Markdown,写了一个独立的 renderContextRecommendationsSystemPrompt(components/ai/context-recommendations-system-prompt.tsx:7),它同样 import NaoContextStructure 复用"磁盘布局说明"那段,并按 proposeFixes / 是否连了 repo 条件渲染不同工具说明。它的产物就是通过 §3.A 末尾说的 create({ systemPrompt }) 高优先级通道注入的。


5. 巧妙之处(可借鉴)

  1. 提示即 UI。 把系统提示当组件树写,让"条件段落""复用片段""嵌套外壳"都变成写 JSX 的日常动作,而不是字符串拼接的地狱。复用尤其明显:NaoContextStructure 一处定义、被主提示和审计提示共享(components/ai/nao-context-structure.tsx:4)。

  2. 自制字符串渲染器,不拉 ReactDOM。 renderToMarkdown 只用了 React 的元素树数据结构,自己递归求值函数组件、从 data-* 属性读排版规则(render-to-markdown.ts:37-62)。轻、无浏览器依赖、可测试。

  3. 两种覆盖 + 占位注入。 项目既能整段换掉提示,又能用 {{nao_prompt}} 只包一层壳而保留 nao 核心指令(agents/system-prompts.ts:12-18services/agent.ts:502-510)——覆盖的粒度做得很细,还能按 provider 分文件。

  4. 覆盖文件的目录逃逸防护。 realpathSync 展开软链后再 isWithinDirectory 校验,把"读到项目外文件"的路堵死(agents/system-prompts.ts:44-59)。提示注入的安全常被忽视,这里做了。

  5. 记忆按优先级 + token 预算裁剪。 有限预算下让全局规则优先进提示(system-prompt.tsx:233-249),而不是简单截断或全塞。


6. 边界与局限

  • 覆盖文件级安全,但内容不清洗。 目录逃逸挡住了,但覆盖文件/RULES.md/记忆的文本内容是原样贴入提示的(system-prompt.tsx:192 直接渲染 userRules)。项目自带的规则文本因此拥有很高的话语权——这是设计使然(项目所有者可信),但意味着能写项目文件的人就能改写 agent 行为。
  • renderToMarkdown 是极简渲染器。 它只认字符串、数字、数组、函数组件、带 data-* 的宿主元素(render-to-markdown.ts);不支持 React 的 hooks、context、状态——组件必须是纯函数。这是刻意的约束,不是缺陷,但写提示组件时不能用 React 生态那套。
  • 记忆超预算会静默丢弃。 超过 1000 token 的记忆直接不进提示(system-prompt.tsx:239-243),模型看不到,也没有提示告知被裁剪了。
  • 占位符只认 {{nao_prompt}} 注入是单占位、全局替换(agents/system-prompts.ts:16-18);没有更细的"只注入 persona / 只注入 SQL 规则"之类分区占位。

7. 代码地图(导航索引)

主题文件路径符号名
装配入口(有无覆盖)apps/backend/src/services/agent.tsAgentManager._buildSystemPrompt
默认提示装配(采集+渲染+套壳)apps/backend/src/services/agent.tsAgentManager._buildDefaultSystemPrompt
运行时整段覆盖短路apps/backend/src/services/agent.ts_buildModelMessages(this._systemPromptOverride ?? …)
找项目覆盖文件(provider→system)apps/backend/src/agents/system-prompts.tsgetSystemPromptOverride / readPromptFile
占位检测与注入apps/backend/src/agents/system-prompts.tshasNaoPromptPlaceholder / injectNaoPrompt
目录逃逸防护apps/backend/src/agents/system-prompts.tsisWithinDirectory(配 realpathSync)
JSX→Markdown 渲染器apps/backend/src/lib/markdown/render-to-markdown.tsrenderToMarkdown
Markdown 原语组件apps/backend/src/lib/markdown/components.tsxBlock / Title / List / CodeBlock
核心系统提示apps/backend/src/components/ai/system-prompt.tsxSystemPrompt / MemoryBlock / getMemoriesInTokenRange
磁盘布局说明(复用片段)apps/backend/src/components/ai/nao-context-structure.tsxNaoContextStructure
渠道回复规范外壳apps/backend/src/components/ai/messaging-provider-system-prompt.tsxMessagingProviderSystemPrompt
fork 选中原文外壳apps/backend/src/components/ai/chat-fork-context-prompt.tsxChatForkContextPrompt
上下文审计提示(同款技术)apps/backend/src/components/ai/context-recommendations-system-prompt.tsxrenderContextRecommendationsSystemPrompt
用户规则 / 连接采集apps/backend/src/agents/user-rules.tsgetUserRules / getConnections
记忆注入 / 抽取apps/backend/src/services/memory.tsMemoryService.safeGetUserMemories
记忆抽取 LLMapps/backend/src/agents/memory/memory-extractor-llm.tsMemoryExtractorLLM.extract
技能加载 / 内容读取apps/backend/src/services/skill.tsSkillService.getSkills / getSkillContent
记忆类别与优先级apps/backend/src/types/memory.tsMEMORY_CATEGORIES