Skip to content

课 3 · Prompt System(提示词系统)

本课目标

理解一个 AI 应用的 Prompt System 全貌——System Prompt 只是其中一环。掌握运行时 Prompt 组装流程、环境上下文设计、Token 预算分配。

前两课聚焦"怎么写好一段 Prompt"。但到了真实产品中,你会发现用户每次提问时,发给模型的内容远不只你手写的那段 System Prompt

工具描述、用户记忆、当前时间、设备信息……这些都会在运行时被拼进最终的 Messages 里。这一课把这个全貌展开讲。

Prompt System 的全景图

一个成熟的 AI 应用发给模型的 Messages,结构通常是这样的:

"写好 System Prompt"只是其中一个格子。我们逐个看其他部分。

各组件的贡献

Static System Prompt(静态系统提示词)

课 1 和课 2 已经讲了——角色、规则、输出格式、拒答模板。这部分是开发时写好的,运行时不变。

工具描述

当你给 AI 定义了工具(tool),SDK 会自动把工具的名称、描述、参数说明拼进 System Message。模型看到这些描述后才知道"我有什么工具可以用"。

typescript
import { tool } from 'ai'
import { z } from 'zod'

const weatherTool = tool({
  description: '查询指定城市的当前天气',  // 这行会被注入 System Message
  inputSchema: z.object({
    city: z.string().describe('城市名称,如"北京"'),  // 这行也会
  }),
  execute: async ({ city }) => {
    return { city, temperature: 22, condition: '晴' }
  },
})

工具描述写得好不好,直接影响模型选不选得对工具。这和 System Prompt 的规则一样重要——只不过它散落在每个 tool()description 和参数的 .describe() 里。

TIP

工具定义的完整用法会在模块 5(Agent 开发)中展开。这一课只需要理解:工具描述是 Prompt System 的一部分,会被自动注入。

记忆上下文

长期记忆的原理是:把用户的历史信息存起来,每次对话开始时检索相关记忆,拼进 System Message:

typescript
// 伪代码:记忆注入流程
async function buildSystemMessage(
  basePrompt: string,
  userId: string,
  currentQuestion: string
) {
  // 1. 从向量数据库检索相关记忆
  const memories = await searchMemories(userId, currentQuestion)

  // 2. 拼进 System Message
  if (memories.length > 0) {
    return `${basePrompt}

<user_memory>
以下是关于当前用户的已知信息,请在回答时参考:
${memories.map(m => `- ${m.content}`).join('\n')}
</user_memory>`
  }

  return basePrompt
}

比如用户上次聊天说过"我用的是 Next.js 14",下次再问路由问题时,模型就知道应该用 App Router 而不是 Pages Router。

TIP

记忆管理的完整实现会在模块 6(记忆管理)中展开。这里只需要知道:记忆是 Prompt System 中动态拼入的一环。

环境上下文

模型不知道"现在几点"、"用户在哪个时区"、"用的是手机还是电脑"。这些运行时信息需要你主动注入:

typescript
interface EnvironmentContext {
  currentTime: string
  timezone: string
  userLocale: string
  platform: 'web' | 'mobile' | 'desktop'
  userName?: string
}

function buildContextBlock(ctx: EnvironmentContext): string {
  return `
<context>
- 当前时间:${ctx.currentTime}
- 时区:${ctx.timezone}
- 用户语言:${ctx.userLocale}
- 使用平台:${ctx.platform}
${ctx.userName ? `- 用户称呼:${ctx.userName}` : ''}
</context>`
}

为什么需要注入这些?看几个场景:

场景需要的上下文没有上下文时模型的行为
用户问"今天天气"currentTime不知道"今天"是哪天
用户说"帮我约下午3点的会"timezone不知道是哪个时区的下午3点
移动端要求简短回复platform可能返回桌面端那种长文
用户说"发个英文版"userLocale不知道用户的默认语言是什么

环境上下文的设计原则:只注入模型需要的信息。用户的密码、Token 这类敏感信息绝对不放进去。

运行时 Prompt 组装流程

把以上所有部分串起来,一个请求从进入到发给模型的完整流程:

typescript
import { streamText, type ModelMessage } from 'ai'
import { createOpenAI } from '@ai-sdk/openai'

const model = createOpenAI({
  apiKey: process.env.CHAT_API_KEY,
  baseURL: 'https://dashscope.aliyuncs.com/compatible-mode/v1',
}).chat('qwen3.6-plus')

// 静态部分:开发时写好
const BASE_SYSTEM_PROMPT = `
<role>
你是「知识库助手」,基于文档回答前端技术问题。
</role>

<rules>
- 使用中文回答,技术术语保持英文
- 不确定的信息标注"不确定"
- 无法回答的问题明确拒答
</rules>

<output_format>
先给结论,再展开解释。如果涉及代码,给出可运行示例。
</output_format>
`

// 动态组装:每次请求时拼接
async function handleUserMessage(
  userInput: string,
  chatHistory: ModelMessage[],
  userId: string
) {
  // Step 1: 从基础 Prompt 开始
  let systemMessage = BASE_SYSTEM_PROMPT

  // Step 2: 注入环境上下文
  systemMessage += buildContextBlock({
    currentTime: new Date().toLocaleString('zh-CN'),
    timezone: Intl.DateTimeFormat().resolvedOptions().timeZone,
    userLocale: 'zh-CN',
    platform: 'web',
  })

  // Step 3: 注入用户记忆(如果有)
  const memories = await searchMemories(userId, userInput)
  if (memories.length > 0) {
    systemMessage += `\n<user_memory>\n${memories.map(m => `- ${m.content}`).join('\n')}\n</user_memory>`
  }

  // Step 4: 工具描述由 SDK 自动注入,不需要手动处理

  // Step 5: 组装完整 Messages
  const messages: ModelMessage[] = [
    ...chatHistory,
    { role: 'user', content: userInput },
  ]

  // Step 6: 发给模型
  console.log('\n📦 最终发给模型的结构:')
  console.log(`  System Message: ${systemMessage.length} 字符`)
  console.log(`  Messages 数量: ${messages.length} 条`)
  console.log(`  工具数量: N 个(SDK 自动注入)\n`)

  return streamText({
    model,
    system: systemMessage,
    messages,
  })
}

// 模拟记忆检索
async function searchMemories(userId: string, query: string) {
  // 实际实现在模块 6,这里用模拟数据
  return [
    { content: '用户偏好 Next.js App Router' },
    { content: '用户的项目使用 TypeScript strict 模式' },
  ]
}

关键理解:开发者手写的 System Prompt 只是最终 System Message 的一部分。运行时,环境上下文、用户记忆等动态信息会不断追加。这也是为什么需要模板化(课 2)——你得有一个清晰的拼装结构,不能每次都手动拼字符串。

Token 预算意识

前面模块 1 已经讲过:对话历史本身就是上下文成本的大头,过长时要靠滑动窗口、Token 预算和摘要来控制。这一节不重复展开裁剪策略,只补一个 Prompt System 视角:除了历史消息,静态 Prompt、工具、记忆、环境上下文也都在抢占上下文窗口。

以 Qwen3.6-Plus 为例,一个典型的预算分配:

组件典型 Token 占用说明
静态 System Prompt200-500角色、规则、输出格式
工具描述500-3000取决于工具数量和参数复杂度
记忆上下文200-1000检索到的用户历史
环境上下文50-200时间、用户信息等
对话历史1000-50000+保留的历史消息
当前输入50-5000用户本次的问题
合计输入2000-60000
模型输出500-4000模型的回答

几个实际问题:

工具多了怎么办? 10 个工具的描述可能就占 2000-3000 Token。如果你的 Agent 有 50 个工具,光工具描述就占了 15000 Token。后续模块会讲工具的动态加载——根据用户问题只注入相关工具。

对话历史太长怎么办? 这个问题前面 01-03 已经讲过,这里只需要补一个判断:做 Prompt System 预算时,不能只盯着历史消息,还要把静态 Prompt、工具描述、记忆注入一起算进去。

怎么估算 Token? 粗略规则:1 个中文字符 ≈ 1-2 Token,1 个英文单词 ≈ 1 Token。精确计算需要用模型对应的 Tokenizer,但做预算规划时粗略估算就够:

typescript
function estimateTokens(text: string): number {
  // 粗略估算:中文按 1.5 Token / 字,英文按 0.25 Token / 字符
  let tokens = 0
  for (const char of text) {
    if (/[\u4e00-\u9fff]/.test(char)) {
      tokens += 1.5
    } else {
      tokens += 0.25
    }
  }
  return Math.ceil(tokens)
}

function printTokenBudget(parts: Record<string, string>) {
  console.log('\n📊 Token 预算分布:')
  let total = 0
  for (const [name, content] of Object.entries(parts)) {
    const tokens = estimateTokens(content)
    total += tokens
    console.log(`  ${name}: ~${tokens} tokens`)
  }
  console.log(`  合计: ~${total} tokens`)
  console.log(`  占 200K 上下文: ${(total / 200000 * 100).toFixed(1)}%\n`)
}

建立 Token 预算意识的目的不是精确到每个 Token,而是让你在设计 Prompt System 时心里有数:前面讲的上下文裁剪解决的是“历史别失控”,这一节讲的是“系统里每个拼进去的组件都有成本”

打印最终 Messages

调试时最有用的手段之一:把实际发给模型的完整 Messages 打印出来,看看模型"看到了什么"。

typescript
function debugMessages(system: string, messages: ModelMessage[]) {
  console.log('╔══════════ 发给模型的完整内容 ══════════╗')
  console.log('┌── System Message ──')
  console.log(system)
  console.log('└────────────────────')
  console.log()

  for (const msg of messages) {
    const role = msg.role === 'user' ? '👤 User' : '🤖 Assistant'
    console.log(`┌── ${role} ──`)
    console.log(typeof msg.content === 'string' ? msg.content : JSON.stringify(msg.content))
    console.log('└────────────────────')
    console.log()
  }

  console.log('╚════════════════════════════════════════╝')
}

很多时候 AI 回答不符合预期,打印完 Messages 才发现:哦,原来是环境上下文注入的时间是 UTC 不是用户时区;或者是记忆里拼了一段过时的信息。先看模型看到了什么,再改 Prompt

并入主线项目

本模块能力会在基础项目 basic/project/ 中收束,当前课内 Demo 见下方运行方式。

这一课的价值是建立全景视角

  1. System Prompt 只是起点 — 一个请求实际包含静态 Prompt + 工具 + 记忆 + 环境上下文 + 对话历史,都会影响模型行为
  2. 组装流程要工程化 — 用函数封装每个组件的拼接逻辑,便于测试和调试
  3. Token 预算要有估算 — 每个组件都有成本,做架构决策时需要考虑

就主线落地节奏而言,basic/project 先承接静态 System Prompt、消息历史、结构化输出与基础拒答;工具、记忆、环境上下文、Token 预算控制会在模块 5、模块 6 以及后续 Agentic RAG 版本里逐步并入。到那时,你已经知道每个组件在 Prompt System 中的位置和作用。

本课产物

  • ✅ Prompt System 全景图:静态 Prompt + 工具 + 记忆 + 环境上下文
  • ✅ 环境上下文设计(时间、时区、语言、平台)
  • ✅ 运行时 Prompt 组装流程
  • ✅ Token 预算估算方法
  • ✅ Prompt 调试方法:打印最终 Messages

完整代码在 basic/examples/02-prompt/03-prompt-system/index.ts

试试看

bash
cd daqi-ai-agent
pnpm exec tsx basic/examples/02-prompt/03-prompt-system/index.ts
  1. assemble 命令查看一个完整请求的 Prompt 组装过程,观察最终 Messages 结构
  2. context 命令切换不同的环境上下文(时区、语言),看对回答的影响
  3. budget 命令输入一段 Prompt,观察各部分 Token 占比

面试追问

Q:一个 AI 应用的 Prompt System 由哪些部分组成?

四个部分:Static System Prompt(静态系统提示词,角色、规则、输出格式)、工具描述(由 SDK 自动注入)、记忆上下文(从向量数据库检索的用户历史信息)、环境上下文(当前时间、用户语言、设备类型等运行时信息)。这些加上对话历史和当前用户输入,构成了发给模型的完整 Messages。

Q:为什么工具描述要写好?

工具描述是 Prompt System 的一部分,模型根据 description 和参数的 .describe() 来决定何时调用哪个工具。描述不准确会导致模型选错工具或在该用工具的时候不用。好的工具描述应该像好的函数文档——说清楚这个工具做什么、什么时候该用、参数是什么含义。

Q:环境上下文应该注入哪些信息?

按需注入模型回答问题所需的运行时信息:当前时间(处理时效性问题)、用户时区(处理跨时区场景)、用户语言(决定回复语言)、使用平台(调整回复的详略程度)。原则是只注入必要信息,不注入敏感数据(密码、Token 等),也不注入与回答无关的信息以节省 Token。

面向前端工程师和独立开发者的 AI 应用工程课程