做 RAG 或 Agent 项目时,很多人最早写出来的代码都差不多:
- 系统提示词一段
- 用户问题一段
- 检索结果一段
- 历史对话一段
- 最后拼成一个超长字符串发给模型
Demo 阶段这么写没有问题,甚至是最快的。但只要项目进入真实业务,这种写法很快就会暴露出三个问题:
- 改不动
- 复用不了
- 不知道哪里在浪费 token
所以这篇文章我不想再讲“Prompt 要怎么写得更高级”,而是直接讲一件更实用的事:
怎么把一个企业知识库助手里的 Prompt,写成一层可维护、可复用、可逐步扩展的工程代码。
全文会围绕一个具体案例展开:做一个企业内部知识库问答助手。它要回答制度、流程、报销、权限申请这类问题;要能接入 RAG 检索结果;要支持多轮对话;后面还可能需要 few-shot 示例来稳定输出格式。
这类场景非常适合用 LangChain 的 Prompt Template 体系来做组件化。
先看最终目标:我们到底要搭什么
我建议把 Prompt 层理解成模型调用前的“上下文装配器”。
它不负责检索文档,也不负责做 embedding,但它要决定:
- 系统角色怎么定义
- 当前问题怎么表达
- 检索结果怎么拼进来
- 历史对话怎么带进去
- 输出格式怎么约束
- 示例什么时候加、加哪些
在一个企业知识库助手里,最终的数据流一般是这样的:
- 用户提问
- 系统去知识库检索相关文档
- 系统裁剪历史对话
- Prompt 层把角色、规则、文档、历史、问题组装成
messages - 模型回答
- 前端把答案和引用展示给用户
这篇文章写完后,你应该能得到一套足够清晰的 Prompt 分层方案:
ChatPromptTemplate作为默认入口PipelinePromptTemplate管理可复用模块partial绑定稳定配置MessagesPlaceholder注入历史消息FewShotPromptTemplate或ExampleSelector处理示例
第一步:先别上复杂组件,先把“字符串拼接”升级成 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,而不是按一整段字符串组织:
如果你从一开始就走 messages 结构,后面所有扩展都会顺很多。
第二步:把 Prompt 拆成可复用模块,而不是每个接口各写一份
企业项目一上来最容易犯的错,就是每个接口都各写一份 Prompt。
比如:
- 知识库问答一个 Prompt
- FAQ 总结一个 Prompt
- 制度解读一个 Prompt
- 新员工 onboarding 一个 Prompt
短期看没有问题,但很快就会发现里面至少有三类内容是重复的:
- 角色定义
- 基础规则
- 输出格式
这时候就该用 PipelinePromptTemplate 了。
它不直接让模型变聪明,但它能让你的 Prompt 从“一次性文本”变成“可装配模块”。
下面这张图就是它最核心的思路:把多个 Prompt 模块合并成最终 Prompt。
对应到代码里,可以这样写:
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 服务台助手”,完全可以复用 policyPrompt 和 outputPrompt,只替换 businessPrompt。
第三步:把稳定变量用 partial 固化掉,别让调用层背一堆参数
很多项目写到这一步后,又会出现第二个问题:变量越来越多。
调用一次 Prompt 可能要传:
tonebusiness_domainaudiencecompany_namesystem_versionquestionretrieved_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 个工作日内申请补卡。
用户:那出差期间也一样吗?
`;
它当然能用,但问题也明显:
- 历史消息失去了角色结构
- 后面想插入
ai、tool、system都不自然 - 很难做统一裁剪和压缩
更稳的做法是:
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
只有当这两个条件同时成立时,我才建议上语义相似示例选择:
- 你的示例库已经比较大
- 不同问题确实应该参考不同风格的示例
它本质上是“给示例库做一层小型向量检索”。这个方案不是不能用,而是别用得太早。
在很多业务里,固定示例 + 长度控制就已经足够了。
一个更完整的可落地版本
把上面这些组件合起来,一个实战版的知识库助手 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 工程化最容易被误解成“写提示词技巧”。但对真正做项目的人来说,它更像一层上下文编排系统。
在企业知识库助手这种场景里,真正值得做的不是不断重写系统提示词,而是把下面这些职责拆清楚:
- 哪些是稳定规则
- 哪些是场景背景
- 哪些是请求级输入
- 哪些是会话级历史
- 哪些是可选示例
当你用 ChatPromptTemplate、PipelinePromptTemplate、partial、MessagesPlaceholder 这些组件把这层搭起来后,Prompt 才会从“能跑的 Demo 字符串”,变成一套能持续演进的生产能力。
如果只记一句实战建议,我更建议记这句:
Prompt 层最重要的工作,不是写一句更厉害的话,而是让模型每次都拿到结构正确、粒度清晰、足够干净的上下文。