04-上下文注入
要点
- 上一节讲的角色设定是稳定的——但每次请求还需要一些动态信息:用户是谁、当前数据是什么、有哪些业务规则
- 上下文注入就是把动态信息塞进 Prompt 的过程
- 四类常见上下文:用户信息、业务数据、参考文档、对话历史
- 注入方式有三种:模板变量、消息拼接、工具调用——适用场景不同
- Token 预算是硬约束——上下文不能无限塞,需要做优先级排序和截断
- 注入的上下文要有明确的结构,模型才能稳定利用
1. 为什么需要上下文注入
看一个场景。后端要做一个「智能订单查询」功能:用户用自然语言问问题,模型根据用户的订单数据回答。
// ❌ 没有上下文
const messages = [
{ role: 'system', content: '你是一个订单查询助手' },
{ role: 'user', content: '我的上一个订单是什么状态?' },
]模型不知道怎么回答。它不知道「我」是谁,也不知道「上一个订单」的数据。
需要把用户信息和订单数据塞进 Prompt:
// ✅ 注入上下文
const messages = [
{ role: 'system', content: '你是一个订单查询助手' },
{
role: 'system',
content: `
## 当前用户
- 用户 ID:10086
- 用户名:张三
## 用户最近的订单
| 订单号 | 金额 | 状态 | 创建时间 |
|---|---|---|---|
| ORD-20240815-001 | ¥299.00 | 已发货 | 2024-08-15 10:30 |
| ORD-20240810-003 | ¥59.90 | 已完成 | 2024-08-10 14:20 |
| ORD-20240805-007 | ¥1299.00 | 已完成 | 2024-08-05 09:15 |
`,
},
{ role: 'user', content: '我的上一个订单是什么状态?' },
]现在模型有了回答所需的全部信息。这个把数据塞进 Prompt 的过程就是上下文注入。
2. 四类常见上下文
实际业务中,需要注入的上下文大致可以分成四类。
第一类:用户信息
当前用户是谁、有什么权限、偏好设置是什么。
const userContext = `
## 当前用户
- 用户 ID: ${user.id}
- 用户名: ${user.name}
- 会员等级: ${user.membershipLevel}
- 可用余额: ${user.balance} 分
`用户信息的来源是数据库或 session,每次请求都不同。它不应该写在 system prompt 常量里——应该动态注入。
第二类:业务数据
当前操作相关的业务数据:订单列表、商品详情、库存状态。
const dataContext = `
## 当前订单详情
订单号: ${order.id}
商品: ${order.items.map(i => `${i.name} x${i.quantity}`).join(', ')}
总金额: ${order.totalAmount} 分
状态: ${order.status}
物流: ${order.trackingNumber || '未发货'}
`业务数据通常来自数据库查询。后端先查数据,再把数据注入 Prompt。
第三类:参考文档
模型需要参考的规则、规范、知识库内容。
const docContext = `
## 退款规则
1. 未发货订单:全额退款,即时到账
2. 已发货订单:需用户先退货,收到退货后 3 个工作日内退款
3. 已签收超过 7 天的订单:不支持退款
4. 定制商品:不支持退款
## 补偿标准
- 发货延迟超过 3 天:补偿 10 元优惠券
- 商品质量问题:全额退款 + 补偿 20 元优惠券
- 物流丢失:全额退款 + 补偿 30 元优惠券
`参考文档可以来自数据库、知识库或静态配置。如果文档量很大,可能需要先做检索(RAG),只注入相关的片段——这是第 14 章的主题。
第四类:对话历史
多轮对话中,之前的对话内容本身就是上下文。第 12 章已经讲过对话历史的管理和截断。这里补充一个细节:对话历史太长的话,可以做一个摘要注入,而不是把所有历史都塞进去。
// src/services/context/history-summary.ts
async function buildHistoryContext(sessionId: string): Promise<string> {
const history = await db.query(
'SELECT role, content FROM messages WHERE session_id = ? ORDER BY created_at ASC',
[sessionId]
)
// 历史消息少于 10 条,直接拼接
if (history.length <= 10) {
return history.map((m) => `${m.role}: ${m.content}`).join('\n')
}
// 历史消息多于 10 条,前 5 条用摘要,后 5 条保留原文
const early = history.slice(0, -5)
const recent = history.slice(-5)
const summary = await summarizeMessages(early)
const recentText = recent.map((m) => `${m.role}: ${m.content}`).join('\n')
return `## 早期对话摘要\n${summary}\n\n## 最近对话\n${recentText}`
}3. 三种注入方式
上下文注入有三种实现方式,适用于不同的场景。
方式一:模板变量
把 Prompt 写成模板,用变量占位,运行时替换。
// src/services/prompt/templates/order-query.ts
// 模板定义
const ORDER_QUERY_TEMPLATE = `你是一个订单查询助手。
## 当前用户
用户名: {{userName}}
会员等级: {{membershipLevel}}
## 用户订单
{{orderList}}
## 任务
根据以上信息回答用户的问题。如果问题涉及订单数据,以上面的订单列表为准。
如果用户问的问题无法从以上信息中回答,明确告知用户。`
// 渲染函数
function renderOrderQueryPrompt(data: {
userName: string
membershipLevel: string
orders: Order[]
}): string {
const orderList = data.orders
.map((o) => `- ${o.id}: ${o.totalAmount}分, ${o.status}`)
.join('\n')
return ORDER_QUERY_TEMPLATE
.replace('{{userName}}', data.userName)
.replace('{{membershipLevel}}', data.membershipLevel)
.replace('{{orderList}}', orderList)
}模板变量适合结构固定、内容动态的场景。模板定义一次,每次只替换变量。
模板引擎也可以用更成熟的方案,比如 handlebars 或 mustache:
import Handlebars from 'handlebars'
const template = Handlebars.compile(ORDER_QUERY_TEMPLATE)
const prompt = template({
userName: user.name,
membershipLevel: user.membershipLevel,
orderList: user.orders.map(formatOrder).join('\n'),
})方式二:消息拼接
把不同的上下文片段拼成多条消息,按顺序发给模型。
// src/services/prompt/context-builder.ts
function buildMessages(user: User, orders: Order[], userQuestion: string): Message[] {
const messages: Message[] = []
// 1. 角色设定(稳定)
messages.push({
role: 'system',
content: '你是一个订单查询助手。根据提供的用户信息和订单数据回答用户问题。',
})
// 2. 用户信息(每次请求不同)
messages.push({
role: 'system',
content: buildUserContext(user),
})
// 3. 业务数据(每次请求不同)
messages.push({
role: 'system',
content: buildOrderContext(orders),
})
// 4. 参考规则(可能动态变化)
messages.push({
role: 'system',
content: buildRuleContext(),
})
// 5. 用户问题
messages.push({
role: 'user',
content: userQuestion,
})
return messages
}消息拼接适合上下文来源多、需要灵活组合的场景。每种上下文独立构建,最后拼在一起。
注意:多条 system message 在某些模型上的行为可能不同。如果遇到问题,可以把所有 system 内容合并成一条:
const systemContent = [
'你是一个订单查询助手。',
buildUserContext(user),
buildOrderContext(orders),
buildRuleContext(),
].join('\n\n')
const messages = [
{ role: 'system', content: systemContent },
{ role: 'user', content: userQuestion },
]方式三:工具调用(动态获取)
模型在对话过程中发现需要额外信息时,主动调用工具获取。
// src/services/prompt/tools/order-tools.ts
const orderTools = [
{
type: 'function' as const,
function: {
name: 'get_user_orders',
description: '查询指定用户的订单列表',
parameters: {
type: 'object',
properties: {
user_id: { type: 'string', description: '用户 ID' },
status: {
type: 'string',
enum: ['all', 'pending', 'shipped', 'completed'],
description: '订单状态筛选',
},
limit: { type: 'number', description: '返回数量上限', default: 10 },
},
required: ['user_id'],
},
},
},
{
type: 'function' as const,
function: {
name: 'get_order_detail',
description: '查询指定订单的详情',
parameters: {
type: 'object',
properties: {
order_id: { type: 'string', description: '订单号' },
},
required: ['order_id'],
},
},
},
]
// 在 Hono 路由中处理工具调用
chatApp.post('/chat', async (c) => {
const { messages, user_id } = await c.req.json()
// 第一步:发给模型,模型可能返回工具调用
let response = await callModelAPI({
model: 'gpt-4o',
messages: [
{ role: 'system', content: '你是订单查询助手。需要订单信息时使用工具查询。' },
{ role: 'system', content: `当前用户 ID: ${user_id}` },
...messages,
],
tools: orderTools,
})
// 第二步:如果模型要调用工具,执行工具并把结果返回给模型
while (response.choices[0].finish_reason === 'tool_calls') {
const toolCalls = response.choices[0].message.tool_calls
const toolResults = await executeToolCalls(toolCalls)
messages.push(response.choices[0].message)
for (const result of toolResults) {
messages.push({
role: 'tool',
tool_call_id: result.toolCallId,
content: result.output,
})
}
response = await callModelAPI({
model: 'gpt-4o',
messages: [
{ role: 'system', content: '你是订单查询助手。' },
...messages,
],
tools: orderTools,
})
}
return c.json(response)
})工具调用适合上下文太大、无法预先全部注入的场景。模型按需查询,不会浪费 Token 在无关数据上。
4. Token 预算管理
上下文不是越多越好。模型的上下文窗口有限(4K、8K、128K……),Token 用量直接影响成本和延迟。
需要做一个 Token 预算管理器:
// src/services/context/token-budget.ts
import { countTokens } from '@/lib/tokenizer'
type ContextSlot = {
name: string
priority: number // 数字越小优先级越高
content: string
}
/**
* 在 Token 预算内,按优先级组装上下文。
* 高优先级的内容完整保留,低优先级的内容按剩余预算截断。
*/
export function assembleContext(
slots: ContextSlot[],
maxTokens: number,
reservedForOutput: number
): string {
const available = maxTokens - reservedForOutput
const sorted = [...slots].sort((a, b) => a.priority - b.priority)
const parts: string[] = []
let usedTokens = 0
for (const slot of sorted) {
const tokens = countTokens(slot.content)
if (usedTokens + tokens <= available) {
// 预算够,完整保留
parts.push(slot.content)
usedTokens += tokens
} else {
// 预算不够,截断
const remaining = available - usedTokens
if (remaining > 50) {
// 至少留 50 token,否则不截断了
const truncated = truncateToTokens(slot.content, remaining)
parts.push(truncated + '\n[...内容已截断]')
usedTokens += remaining
}
break
}
}
return parts.join('\n\n')
}
function truncateToTokens(text: string, maxTokens: number): string {
// 简单实现:按字符比例截断
const totalTokens = countTokens(text)
if (totalTokens <= maxTokens) return text
const ratio = maxTokens / totalTokens
const charLimit = Math.floor(text.length * ratio)
return text.slice(0, charLimit)
}使用方式:
const context = assembleContext(
[
{ name: 'role', priority: 0, content: SYSTEM_PROMPT },
{ name: 'user', priority: 1, content: buildUserContext(user) },
{ name: 'orders', priority: 2, content: buildOrderContext(orders) },
{ name: 'rules', priority: 3, content: RULES },
],
8000, // 模型上下文窗口
2000 // 预留给模型输出
)优先级规则:
| 优先级 | 内容 | 原因 |
|---|---|---|
| 0 | 角色设定 | 去掉就不知道模型该做什么 |
| 1 | 用户信息 | 不知道用户是谁就无法个性化回答 |
| 2 | 业务数据 | 没有数据模型只能泛泛而谈 |
| 3 | 参考规则 | 有更好,没有也能回答 |
5. 上下文注入的结构设计
注入的上下文要有明确的结构,模型才能稳定利用。两种常用结构:
结构一:Markdown 标题分区
const context = `
## 用户信息
用户名: 张三
会员等级: VIP
## 订单数据
| 订单号 | 金额 | 状态 |
|---|---|---|
| ORD-001 | 29900 | 已发货 |
| ORD-002 | 5990 | 已完成 |
## 退款规则
1. 未发货:全额退款
2. 已发货:退货后退款
`模型对 Markdown 标题有较好的理解能力。标题形成了分区,模型知道去哪里找对应的信息。
结构二:键值对
const context = `
user_name: 张三
membership_level: VIP
order_1_id: ORD-001
order_1_amount: 29900
order_1_status: 已发货
order_2_id: ORD-002
order_2_amount: 5990
order_2_status: 已完成
`键值对更紧凑,Token 消耗更少。但可读性差一些,适合 Token 预算紧张的场景。
6. 一个完整的上下文注入示例
// src/services/prompt/order-query.ts
import { Hono } from 'hono'
import { assembleContext } from '@/services/context/token-budget'
import { getUserById, getOrdersByUserId } from '@/services/db'
const SYSTEM_PROMPT = `你是一个电商订单查询助手。
## 职责
根据提供的用户信息和订单数据,回答用户关于订单的问题。
## 规则
1. 只根据提供的订单数据回答,不编造数据
2. 如果问题无法从提供的数据中回答,告知用户「我目前无法查询到此信息」
3. 金额显示转换为元(除以 100),保留两位小数
4. 日期显示使用 YYYY-MM-DD 格式`
const RULES = `
## 退款规则
1. 未发货订单:全额退款,即时到账
2. 已发货订单:需用户先退货,收到退货后 3 个工作日内退款
3. 已签收超过 7 天的订单:不支持退款
`
const app = new Hono()
app.post('/order-query', async (c) => {
const { user_id, question } = await c.req.json()
// 1. 查数据
const user = await getUserById(user_id)
const orders = await getOrdersByUserId(user_id, { limit: 10 })
// 2. 组装上下文
const context = assembleContext(
[
{ name: 'role', priority: 0, content: SYSTEM_PROMPT },
{
name: 'user',
priority: 1,
content: `## 用户信息\n用户名: ${user.name}\n会员等级: ${user.membershipLevel}`,
},
{
name: 'orders',
priority: 2,
content: formatOrders(orders),
},
{ name: 'rules', priority: 3, content: RULES },
],
8000,
1000
)
// 3. 调用模型
const response = await callModelAPI({
model: 'gpt-4o',
messages: [
{ role: 'system', content: context },
{ role: 'user', content: question },
],
temperature: 0,
})
return c.json({ answer: response.choices[0].message.content })
})
function formatOrders(orders: Order[]): string {
const lines = orders.map(
(o) => `- ${o.id}: ¥${(o.totalAmount / 100).toFixed(2)}, ${o.status}, ${o.createdAt}`
)
return `## 最近订单\n${lines.join('\n')}`
}
export default app总结
回顾这一节的要点:
- 上下文注入把动态信息(用户数据、业务数据、参考文档、对话历史)塞进 Prompt
- 三类数据、三种注入方式:模板变量、消息拼接、工具调用
- Token 预算是硬约束,需要做优先级排序和截断
- 上下文要有明确的结构(Markdown 标题或键值对),模型才能稳定利用
- 角色设定是稳定的常量,上下文是动态的变量——两者不要混在一起
下一篇讲约束条件设计——怎么限制模型的行为边界,让它不越界、不编造、不跑偏。