别再手拼 Prompt 了:从零搭一个可维护的 Prompt 层,做企业知识库助手的实战版

0 阅读15分钟

做 RAG 或 Agent 项目时,很多人最早写出来的代码都差不多:

  • 系统提示词一段
  • 用户问题一段
  • 检索结果一段
  • 历史对话一段
  • 最后拼成一个超长字符串发给模型

Demo 阶段这么写没有问题,甚至是最快的。但只要项目进入真实业务,这种写法很快就会暴露出三个问题:

  1. 改不动
  2. 复用不了
  3. 不知道哪里在浪费 token

所以这篇文章我不想再讲“Prompt 要怎么写得更高级”,而是直接讲一件更实用的事:

怎么把一个企业知识库助手里的 Prompt,写成一层可维护、可复用、可逐步扩展的工程代码。

全文会围绕一个具体案例展开:做一个企业内部知识库问答助手。它要回答制度、流程、报销、权限申请这类问题;要能接入 RAG 检索结果;要支持多轮对话;后面还可能需要 few-shot 示例来稳定输出格式。

这类场景非常适合用 LangChain 的 Prompt Template 体系来做组件化。

先看最终目标:我们到底要搭什么

我建议把 Prompt 层理解成模型调用前的“上下文装配器”。

它不负责检索文档,也不负责做 embedding,但它要决定:

  • 系统角色怎么定义
  • 当前问题怎么表达
  • 检索结果怎么拼进来
  • 历史对话怎么带进去
  • 输出格式怎么约束
  • 示例什么时候加、加哪些

在一个企业知识库助手里,最终的数据流一般是这样的:

  1. 用户提问
  2. 系统去知识库检索相关文档
  3. 系统裁剪历史对话
  4. Prompt 层把角色、规则、文档、历史、问题组装成 messages
  5. 模型回答
  6. 前端把答案和引用展示给用户

这篇文章写完后,你应该能得到一套足够清晰的 Prompt 分层方案:

  • ChatPromptTemplate 作为默认入口
  • PipelinePromptTemplate 管理可复用模块
  • partial 绑定稳定配置
  • MessagesPlaceholder 注入历史消息
  • FewShotPromptTemplateExampleSelector 处理示例

第一步:先别上复杂组件,先把“字符串拼接”升级成 ChatPromptTemplate

很多项目起步都像这样:

const prompt = `
你是企业知识库助手,请根据下面资料回答问题。

资料:
${retrievedDocs}

用户问题:
${question}

请先给结论,再给依据,最后给建议。
`;

问题不在于它能不能跑,而在于它没有结构。

如果你用的是聊天模型,默认就应该改成 ChatPromptTemplate,因为它更符合模型接口本身的输入形态。

import { ChatPromptTemplate } from '@langchain/core/prompts';

const baseQaPrompt = ChatPromptTemplate.fromMessages([
  [
    'system',
    `你是一名企业内部知识库助手。
回答时遵循以下规则:
1. 优先依据提供的资料回答
2. 不要编造制度细节
3. 如果资料不足,明确说明“当前资料不足以确认”
4. 回答结构固定为:结论 -> 依据 -> 建议`,
  ],
  [
    'human',
    `当前问题:{question}

已检索资料:
{retrieved_context}`,
  ],
]);

这段代码很基础,但已经比拼字符串稳定很多了。因为它至少完成了两件事:

  • 把高优先级规则放进了 system
  • 把请求级动态输入放进了 human

这个分层非常重要。后面无论你要加历史消息、few-shot 还是工具结果,都不会打乱现有结构。

为什么企业知识库助手更适合 ChatPromptTemplate

知识库问答看起来像“单轮问答”,但实际项目里很少真的只有一段文本。

你通常至少会遇到这些上下文:

  • 平台级规则:不能乱编、必须给依据
  • 业务级背景:这是 HR 制度库还是 IT 服务台
  • 会话级上下文:上一轮用户刚问过什么
  • 请求级数据:这次检索回来的文档

这些内容天然更适合按消息角色组织,而不是混成一个纯文本大串。

下面这张原图其实挺适合说明这一点,ChatPromptTemplate 的本质就是按消息角色组织 Prompt,而不是按一整段字符串组织:

Clipboard_Screenshot_1775313749.png

如果你从一开始就走 messages 结构,后面所有扩展都会顺很多。

第二步:把 Prompt 拆成可复用模块,而不是每个接口各写一份

企业项目一上来最容易犯的错,就是每个接口都各写一份 Prompt。

比如:

  • 知识库问答一个 Prompt
  • FAQ 总结一个 Prompt
  • 制度解读一个 Prompt
  • 新员工 onboarding 一个 Prompt

短期看没有问题,但很快就会发现里面至少有三类内容是重复的:

  • 角色定义
  • 基础规则
  • 输出格式

这时候就该用 PipelinePromptTemplate 了。

它不直接让模型变聪明,但它能让你的 Prompt 从“一次性文本”变成“可装配模块”。

下面这张图就是它最核心的思路:把多个 Prompt 模块合并成最终 Prompt。

Clipboard_Screenshot_1775313768.png

对应到代码里,可以这样写:

import {
  PromptTemplate,
  PipelinePromptTemplate,
  ChatPromptTemplate,
} from '@langchain/core/prompts';

const rolePrompt = PromptTemplate.fromTemplate(
  `你是一名企业内部知识库助手,回答风格:{tone}。`
);

const policyPrompt = PromptTemplate.fromTemplate(
  `请遵循以下回答原则:
1. 只基于资料回答
2. 不要补全未出现的制度条款
3. 如果资料冲突,优先最近版本
4. 如果资料不足,明确指出不确定性`
);

const businessPrompt = PromptTemplate.fromTemplate(
  `当前服务场景:{business_domain}
当前服务对象:{audience}`
);

const outputPrompt = PromptTemplate.fromTemplate(
  `请按以下结构输出:
1. 结论
2. 依据
3. 风险提示
4. 下一步建议

如引用资料,请标注来源标题。`
);

const finalPrompt = ChatPromptTemplate.fromMessages([
  [
    'system',
    `{role_block}
{policy_block}
{business_block}
{output_block}`,
  ],
  [
    'human',
    `用户问题:{question}

检索结果:
{retrieved_context}`,
  ],
]);

const qaPipelinePrompt = new PipelinePromptTemplate({
  pipelinePrompts: [
    { name: 'role_block', prompt: rolePrompt },
    { name: 'policy_block', prompt: policyPrompt },
    { name: 'business_block', prompt: businessPrompt },
    { name: 'output_block', prompt: outputPrompt },
  ],
  finalPrompt,
  inputVariables: [
    'tone',
    'business_domain',
    'audience',
    'question',
    'retrieved_context',
  ],
});

这段代码在整个链路里做了什么

  • rolePrompt 负责角色与语气
  • policyPrompt 负责稳定的业务规则
  • businessPrompt 负责不同业务域的背景差异
  • outputPrompt 负责输出契约
  • finalPrompt 负责把这些模块和当前请求拼成最终 messages

你会发现,这时候 Prompt 的复用粒度终于清晰了。

例如未来你要做“IT 服务台助手”,完全可以复用 policyPromptoutputPrompt,只替换 businessPrompt

第三步:把稳定变量用 partial 固化掉,别让调用层背一堆参数

很多项目写到这一步后,又会出现第二个问题:变量越来越多。

调用一次 Prompt 可能要传:

  • tone
  • business_domain
  • audience
  • company_name
  • system_version
  • question
  • retrieved_context

其中真正每次都变的,其实只有后两个。

这时候就该用 partial,把稳定变量先绑定进去。

const runtimePrompt = await qaPipelinePrompt.partial({
  tone: '准确、克制、偏执行导向',
  business_domain: 'HR 制度知识库',
  audience: '公司内部员工',
});

const promptValue = await runtimePrompt.formatPromptValue({
  question: '出差报销的电子发票最晚多久提交?',
  retrieved_context: retrievedContext,
});

为什么这里一定要用 partial

因为它本质上是在做“运行环境配置”和“单次请求数据”的分层。

如果你不这样做,后面常见的问题就是:

  • 每个调用方都要知道一堆并不属于它的配置
  • 环境变量和请求变量混在一起
  • 一旦统一风格修改,要改很多地方

partial 不是为了省两行代码,而是为了让 Prompt 调用接口更稳定。

第四步:多轮对话不要手工拼 history,直接用 MessagesPlaceholder

企业知识库助手一旦被真正用起来,多轮问题很常见。

比如用户先问:

忘记打卡后怎么办?

下一轮接着问:

那出差期间也一样吗?

如果你只看第二句,模型根本不知道“也一样吗”在指什么。

最常见但也最糙的处理方式,是把历史拼成这样:

const history = `
用户:忘记打卡后怎么办?
助手:可以在 3 个工作日内申请补卡。
用户:那出差期间也一样吗?
`;

它当然能用,但问题也明显:

  • 历史消息失去了角色结构
  • 后面想插入 aitoolsystem 都不自然
  • 很难做统一裁剪和压缩

更稳的做法是:

import {
  ChatPromptTemplate,
  MessagesPlaceholder,
} from '@langchain/core/prompts';

const multiTurnPrompt = ChatPromptTemplate.fromMessages([
  [
    'system',
    `你是一名企业知识库助手。若历史对话与本轮检索结果冲突,以本轮检索结果为准。`,
  ],
  new MessagesPlaceholder('history'),
  [
    'human',
    `当前检索资料:
{retrieved_context}

本轮问题:
{question}`,
  ],
]);

调用时传入:

const promptValue = await multiTurnPrompt.formatPromptValue({
  history: [
    { role: 'human', content: '忘记打卡后怎么办?' },
    { role: 'ai', content: '可以在 3 个工作日内通过 OA 发起补卡申请。' },
  ],
  question: '那出差期间也一样吗?',
  retrieved_context,
});

这一步最容易踩的坑

不是 MessagesPlaceholder 不好用,而是很多人会顺手把全部历史都塞进去。

这会造成两个后果:

  • token 成本持续上升
  • 旧上下文开始污染当前问题

所以我的建议是:

  • 默认只保留最近几轮
  • 长会话要先做摘要
  • 历史裁剪放在模板外实现,不要写死在 Prompt 里

第五步:先把检索结果整理干净,再塞进 Prompt

很多 RAG 项目回答不稳,不是 Prompt 写得差,而是检索结果喂得太粗。

比如你从向量库拿回 5 条文档后,直接这样拼:

const retrievedContext = docs.map((doc) => doc.pageContent).join('\n\n');

能跑,但通常不够好。因为模型拿到的是一团未整理文本,很难快速判断:

  • 哪条是主依据
  • 哪条是补充信息
  • 哪些文档其实重复

更实战一点的做法,是先做一个轻量整理层:

function buildRetrievedContext(docs) {
  return docs
    .slice(0, 4)
    .map((doc, index) => {
      const title = doc.metadata?.title ?? `文档${index + 1}`;
      const source = doc.metadata?.source ?? 'unknown';
      const content = doc.pageContent.trim();

      return [
        `[资料${index + 1}]`,
        `标题:${title}`,
        `来源:${source}`,
        `内容:${content}`,
      ].join('\n');
    })
    .join('\n\n');
}

这段代码为什么值得加

因为它在 Prompt 之前先做了三件小事:

  • 给每段资料编号
  • 保留标题和来源
  • 让模型更容易在回答里引用依据

这类预处理虽然简单,但对知识库问答非常实用。很多时候,回答质量的提升来自“上下文更干净”,而不是“Prompt 更花哨”。

第六步:当输出风格开始飘时,再考虑 few-shot

知识库助手做到后面,常见的新问题不是“答不出来”,而是“答出来了,但格式不稳定”。

比如同样是制度问答:

  • 有时先给结论
  • 有时先复述问题
  • 有时写成一段散文
  • 有时完全不引用依据

这时候可以加 few-shot,让模型学习一两个“你想要的答案长什么样”。

import {
  FewShotPromptTemplate,
  PromptTemplate,
} from '@langchain/core/prompts';

const examplePrompt = PromptTemplate.fromTemplate(
  `问题:{question}
示例回答:
{answer}
---`
);

const examples = [
  {
    question: '忘记打卡后如何补卡?',
    answer:
      '结论:可以补卡。\n依据:考勤制度第 4.2 条说明员工可在 3 个工作日内发起补卡申请。\n建议:通过 OA 提交补卡并附上说明。',
  },
  {
    question: '离职后还能继续报销吗?',
    answer:
      '结论:通常不能按正常流程继续报销。\n依据:离职流程完成后,常规报销权限会关闭。\n建议:如存在特殊费用,请联系 HRBP 或财务确认补提交流程。',
  },
];

const fewShotPrompt = new FewShotPromptTemplate({
  examples,
  examplePrompt,
  prefix: '下面是两个高质量回答示例,请学习它们的结构:',
  suffix: '现在请回答新问题:{question}',
  inputVariables: ['question'],
});

few-shot 最重要的判断,不是“能不能加”,而是“该不该加”

我建议你把它当成一个风格稳定器,而不是默认配置。

适合加 few-shot 的情况:

  • 你要固定输出结构
  • 你要控制语气
  • 你要减少回答格式抖动

不适合拿它来解决的情况:

  • 检索召回本身不准
  • 文档依据不足
  • 业务规则冲突
  • 模型在强行脑补

这些问题不先解决,few-shot 只会把错误答案包装得更像正确答案。

第七步:示例一多时,再引入 ExampleSelector

如果你只有 2 到 3 条示例,固定 few-shot 就够了。

但如果你的知识库助手后面开始分场景:

  • HR 制度
  • IT 支持
  • 采购流程
  • 法务合规

这时示例也会越来越多。全部塞进 Prompt 不现实,这时才轮到 ExampleSelector

默认先上 LengthBasedExampleSelector

如果你现在最痛的是 token 成本,默认先上基于长度的选择器,而不是语义选择器。

import { LengthBasedExampleSelector } from '@langchain/core/example_selectors';

const selector = await LengthBasedExampleSelector.fromExamples(examples, {
  examplePrompt,
  maxLength: 900,
  getTextLength: (text) => text.length,
});

这里几个点要说清楚:

  • maxLength 是示例预算,不是整个请求预算
  • getTextLength 在 demo 里用字符数就够,但生产环境最好按 token 近似

什么时候再上 SemanticSimilarityExampleSelector

只有当这两个条件同时成立时,我才建议上语义相似示例选择:

  1. 你的示例库已经比较大
  2. 不同问题确实应该参考不同风格的示例

它本质上是“给示例库做一层小型向量检索”。这个方案不是不能用,而是别用得太早。

在很多业务里,固定示例 + 长度控制就已经足够了。

一个更完整的可落地版本

把上面这些组件合起来,一个实战版的知识库助手 Prompt 层大致可以写成这样:

import 'dotenv/config';
import { ChatOpenAI } from '@langchain/openai';
import {
  ChatPromptTemplate,
  MessagesPlaceholder,
  PipelinePromptTemplate,
  PromptTemplate,
} from '@langchain/core/prompts';

const model = new ChatOpenAI({
  model: process.env.MODEL_NAME,
  apiKey: process.env.OPENAI_API_KEY,
  temperature: 0,
  configuration: {
    baseURL: process.env.OPENAI_BASE_URL,
  },
});

const rolePrompt = PromptTemplate.fromTemplate(
  `你是一名企业内部知识库助手,风格要求:{tone}。`
);

const rulesPrompt = PromptTemplate.fromTemplate(
  `回答规则:
1. 只依据资料作答
2. 若资料不足,直接说明
3. 若资料冲突,优先最近版本
4. 不要编造制度编号`
);

const formatPrompt = PromptTemplate.fromTemplate(
  `请按以下结构输出:
1. 结论
2. 依据
3. 建议`
);

const finalPrompt = ChatPromptTemplate.fromMessages([
  ['system', '{role_block}\n{rules_block}\n{format_block}'],
  new MessagesPlaceholder('history'),
  [
    'human',
    `用户问题:{question}

检索资料:
{retrieved_context}`,
  ],
]);

const prompt = new PipelinePromptTemplate({
  pipelinePrompts: [
    { name: 'role_block', prompt: rolePrompt },
    { name: 'rules_block', prompt: rulesPrompt },
    { name: 'format_block', prompt: formatPrompt },
  ],
  finalPrompt,
  inputVariables: ['tone', 'history', 'question', 'retrieved_context'],
});

const runtimePrompt = await prompt.partial({
  tone: '准确、专业、尽量少废话',
});

const promptValue = await runtimePrompt.formatPromptValue({
  history: [
    { role: 'human', content: '忘记打卡后怎么办?' },
    { role: 'ai', content: '可在 3 个工作日内通过 OA 发起补卡申请。' },
  ],
  question: '那出差期间也一样吗?',
  retrieved_context: buildRetrievedContext(docs),
});

const result = await model.invoke(promptValue.toChatMessages());
console.log(result.content);

这份代码为什么适合当起点

因为它已经把 Prompt 层最容易失控的几个部分拆开了:

  • 角色
  • 规则
  • 输出格式
  • 历史消息
  • 当前问题
  • 检索资料

之后你再做增强,也有明确落点:

  • 要换语气:改 rolePrompt
  • 要改引用规则:改 rulesPrompt
  • 要改输出结构:改 formatPrompt
  • 要加历史压缩:改 history 的生成逻辑
  • 要加 few-shot:在 finalPrompt 前面插入示例块

这套写法在工程里最值钱的地方

不是“代码更优雅”,而是后面维护成本明显下降。

比如下面这些需求变化,在这种结构里都不难接:

  • HR 要求制度问答必须附来源标题
  • 财务知识库需要更保守的措辞
  • IT 服务台想复用同一套输出格式
  • 对话超过 10 轮时,要自动压缩历史
  • 新场景需要单独的 few-shot 示例

如果你的 Prompt 还是一大段字符串,这些需求几乎都会演变成“改一处、炸三处”。

实战里最容易踩的几个坑

1. Pipeline 拆得太细

拆分目标是清晰,不是拼模块数量。角色、规则、格式、任务,这几个层次通常就够了。别把一句话拆成一个模板。

2. history 全量塞入

多轮对话不是聊天记录备份。无关历史越多,当前回答越容易跑偏。

3. 检索结果不整理直接进 Prompt

这会让模型花大量注意力处理噪声。先做轻量清洗和编号,收益通常比多写几句 Prompt 更大。

4. few-shot 加得过早

先解决召回和规则问题,再考虑输出风格。顺序不要反。

5. 把 Prompt 当常量文案

生产系统里的 Prompt 更像一层运行时配置。它应该能被装配、被替换、被裁剪,而不是写死在业务代码里。

我的默认选型建议

如果你现在要落地一个中小型知识库助手,我会建议这样做:

  • 默认使用 ChatPromptTemplate
  • Prompt 一旦跨场景复用,就引入 PipelinePromptTemplate
  • 稳定配置用 partial
  • 多轮对话用 MessagesPlaceholder
  • 输出风格不稳时,再加 few-shot
  • 示例库变大后,先上 LengthBasedExampleSelector
  • 只有示例确实要按语义分流时,再考虑 SemanticSimilarityExampleSelector

这个顺序的核心思路是:先解决结构问题,再解决风格问题,最后才解决示例选择问题。

总结

Prompt 工程化最容易被误解成“写提示词技巧”。但对真正做项目的人来说,它更像一层上下文编排系统。

在企业知识库助手这种场景里,真正值得做的不是不断重写系统提示词,而是把下面这些职责拆清楚:

  • 哪些是稳定规则
  • 哪些是场景背景
  • 哪些是请求级输入
  • 哪些是会话级历史
  • 哪些是可选示例

当你用 ChatPromptTemplatePipelinePromptTemplatepartialMessagesPlaceholder 这些组件把这层搭起来后,Prompt 才会从“能跑的 Demo 字符串”,变成一套能持续演进的生产能力。

如果只记一句实战建议,我更建议记这句:

Prompt 层最重要的工作,不是写一句更厉害的话,而是让模型每次都拿到结构正确、粒度清晰、足够干净的上下文。