Appearance
课 3 · Prompt System(提示词系统)
本课目标
理解一个 AI 应用的 Prompt System 全貌——System Prompt 只是其中一环。掌握运行时 Prompt 组装流程、环境上下文设计、Token 预算分配。
前两课聚焦"怎么写好一段 Prompt"。但到了真实产品中,你会发现用户每次提问时,发给模型的内容远不只你手写的那段 System Prompt。
工具描述、用户记忆、当前时间、设备信息……这些都会在运行时被拼进最终的 Messages 里。这一课把这个全貌展开讲。
Prompt System 的全景图
一个成熟的 AI 应用发给模型的 Messages,结构通常是这样的:
最终 Messages 的组成
System Message
静态系统提示词角色 / 规则 / 输出格式
工具描述SDK 自动注入
记忆上下文检索到的用户记忆
环境上下文时间 / 用户 / 设备
Messages 历史 + 当前输入
User Message之前的对话
Assistant Message之前的回复
Tool Result工具调用结果
当前 User Message用户本次输入
"写好 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 组装流程
把以上所有部分串起来,一个请求从进入到发给模型的完整流程:
运行时 Prompt 组装流程
用户输入
→
基础 System PromptBASE_SYSTEM_PROMPT
→
环境上下文
↓
用户记忆
→
工具描述
→
最终请求system + messages
工具描述由 SDK 自动注入;环境、记忆、历史和当前输入都需要在本轮请求前组装好。
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 Prompt | 200-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 见下方运行方式。
这一课的价值是建立全景视角:
- System Prompt 只是起点 — 一个请求实际包含静态 Prompt + 工具 + 记忆 + 环境上下文 + 对话历史,都会影响模型行为
- 组装流程要工程化 — 用函数封装每个组件的拼接逻辑,便于测试和调试
- 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- 用
assemble命令查看一个完整请求的 Prompt 组装过程,观察最终 Messages 结构 - 用
context命令切换不同的环境上下文(时区、语言),看对回答的影响 - 用
budget命令输入一段 Prompt,观察各部分 Token 占比
面试追问
Q:一个 AI 应用的 Prompt System 由哪些部分组成?
四个部分:Static System Prompt(静态系统提示词,角色、规则、输出格式)、工具描述(由 SDK 自动注入)、记忆上下文(从向量数据库检索的用户历史信息)、环境上下文(当前时间、用户语言、设备类型等运行时信息)。这些加上对话历史和当前用户输入,构成了发给模型的完整 Messages。
Q:为什么工具描述要写好?
工具描述是 Prompt System 的一部分,模型根据 description 和参数的 .describe() 来决定何时调用哪个工具。描述不准确会导致模型选错工具或在该用工具的时候不用。好的工具描述应该像好的函数文档——说清楚这个工具做什么、什么时候该用、参数是什么含义。
Q:环境上下文应该注入哪些信息?
按需注入模型回答问题所需的运行时信息:当前时间(处理时效性问题)、用户时区(处理跨时区场景)、用户语言(决定回复语言)、使用平台(调整回复的详略程度)。原则是只注入必要信息,不注入敏感数据(密码、Token 等),也不注入与回答无关的信息以节省 Token。