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)
}

模板变量适合结构固定、内容动态的场景。模板定义一次,每次只替换变量。

模板引擎也可以用更成熟的方案,比如 handlebarsmustache

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 标题或键值对),模型才能稳定利用
  • 角色设定是稳定的常量,上下文是动态的变量——两者不要混在一起

下一篇讲约束条件设计——怎么限制模型的行为边界,让它不越界、不编造、不跑偏。