- AI 应用
- AI 写作
- 人工智能
- AI Agent
- 企业应用
【免费下载链接】OpenBidKit_Yibiao
开箱即用的AI标书编写工具,标书AI生成工具,投标工具箱、知识库、标书查重、废标项检查,完全开源免费,欢迎使用
在 AI 标书编写工具中,一份几万字的招标文件会被反复送入模型:解析项目概述、解析技术评分要求、生成目录、逐章生成正文。绝大多数团队把精力花在"精简提示词"上,却忽略了一个更直接的成本杠杆——提示词的排列顺序。只要把稳定的大上下文前置、把每次请求的任务差异后置,模型服务商的前缀缓存(Prompt Cache)就能持续命中,输入成本可以下降近一个数量级。
本篇以标书智能体(OpenBidKit_Yibiao)为实战背景,完整讲解"提示词顺序优化"这一思路的来源、三类典型场景(文档解析、提纲生成、正文生成)的改造方案、四条可复用的组织原则,并结合仓库源码验证这一思路在生产代码中的落地方式。读完本文,你将掌握一套适用于任何高频 LLM 任务的"前缀友好型"提示词编排方法。
一、为什么吃不到缓存:缓存看的是"前缀",不是"内容相同"
很多大模型服务商都支持 Prompt Cache,或类似的输入缓存机制。但缓存命中并非"只要内容一样就行",在大多数实现里,它匹配的是请求前缀——请求开头那一大段内容是否稳定、是否一致、是否每次都出现在同样的位置。
反例很容易构造:如果你把一份很长的正文放在提示词后面,而前面先拼上了很多每次都不一样的说明(比如任务名、时间戳、随机变量),那么这份长文本虽然内容完全相同,服务商也不会把它识别成同一个可复用前缀,缓存自然无法命中。
以文档解析功能为例:对同一份招标文件,系统需要做两次分析——提取项目概述、提取技术评分要求。最初的写法是典型的"任务前置、大文本后置":
def build_analysis_messages(file_content: str, analysis_type: str) -> List[Dict[str, str]]: if analysis_type == 'overview': system_prompt = '提取项目概述的 system prompt' analysis_type_cn = '项目概述' else: system_prompt = '提取技术评分要求的 system prompt' analysis_type_cn = '技术评分要求' user_prompt = ( f'请分析以下招标文件内容,提取{analysis_type_cn}信息:\n\n{file_content}' ) return [ {'role': 'system', 'content': system_prompt}, {'role': 'user', 'content': user_prompt}, ]问题不在file_content——恰恰相反,两次请求的file_content完全相同。问题在于:
- 两次请求的
system_prompt不一样; - 两次请求的
user_prompt前缀也不一样; - 真正占 token 的大文本
file_content被放到了最后。
于是服务商看到的是"两个不同的请求,末尾恰好有一段相同的内容",而不是"同一个长前缀"。缓存命中率自然高不起来。
二、解决方案:把大而稳定的上下文放前面,任务差异放最后
想通之后,改造非常简单,原则只有一句话:
把大且稳定的上下文放前面,把这次请求的任务差异放最后。
改造后的文档解析提示词:
def build_analysis_messages(file_content: str, analysis_type: str) -> List[Dict[str, str]]: system_prompt = """你是一名专业的招标文件分析助手。请严格基于用户提供的招标文件原文完成分析任务。 通用要求: 1. 保持提取信息的全面性和准确性,尽量使用原文内容,不要自行编造 2. 只输出最终分析结果,不要输出额外说明、过程、提示语或客套话 3. 如果文档内容不足以支持某项结论,应明确说明原文未提及,不要凭空补充 """ file_prompt = f"""以下是完整招标文件全文,请先完整阅读,并仅基于原文完成后续任务: {file_content}""" if analysis_type == 'overview': task_prompt = '任务:提取并总结项目概述信息。...' else: task_prompt = '任务:提取技术评分要求。...' return [ {'role': 'system', 'content': system_prompt}, {'role': 'user', 'content': file_prompt}, {'role': 'user', 'content': task_prompt}, ]改造后两个请求的共同前缀非常清晰:
- 第一条
system完全一样; - 第二条"全文
user"完全一样; - 只有最后一条任务说明不同。
这种"稳定前缀 + 尾部差异"的结构,比"先写不同任务、再塞同一份全文"的方式更容易命中缓存。
仓库实证:招标解析的"前缀友好"实现
当前仓库的招标文件解析实现与上述思路完全同构,核心代码在 bidAnalysisTask.cjs:
- 稳定的 system 提示词:
stableSystemPrompt(第 12-18 行)固定为"投标资料分析助手 + 通用要求",不随任务变化。 - 大文本前置:
buildTenderContextMessages(第 202-211 行)把完整招标文件作为一条独立的user消息放在最前面:
function buildTenderContextMessages(fileContent, sectionHint) { const messages = [ { role: 'system', content: stableSystemPrompt }, ]; if (sectionHint) { messages.push({ role: 'system', content: sectionHint }); } messages.push({ role: 'user', content: `以下是完整招标文件。后续任务需要基于这份招标文件完成;如后续消息提供补充上下文,请按具体任务要求综合使用:\n\n${fileContent}` }); return messages; }- 任务差异后置:
buildMessages(第 213-219 行)在此基础上再追加一条只包含当前任务说明的user消息,也就是buildTaskPrompt(task)。 - 任务本身也被拆成了"稳定 JSON 模板 + 变化部分":
jsonTask(title, goals, outputJson)(第 20-34 行)把输出 JSON 的骨架固定下来,只让 title/goals 变化,保证同批任务间的格式前缀尽可能一致。
更关键的是,仓库还把"缓存预热"做成了显式流程:runBidAnalysisTask(第 437-450 行)先串行执行一次项目概述解析,随后waitForPromptCacheWarmup()等待PROMPT_CACHE_WARMUP_DELAY_MS = 5000毫秒(第 5-10 行),日志里明确写着"提示词缓存预热完成,等待 5 秒后开始并发解析剩余项",然后再用Promise.all并发执行其余十几个解析任务。这样第一批并发请求就能直接吃到第一条请求写入的前缀缓存。
三、提纲编写部分优化:多消息拆分替代单一大 prompt
生成提纲时也有同样的问题。同一个项目里,用户经常会多次点击"重新生成目录",于是同一份overview和requirements会被反复发给模型。
旧写法通常是把它们拼成一个大user_prompt:
user_prompt = f"""请基于以下项目信息生成标书目录结构: 项目概述: {overview} 技术评分要求: {requirements} 请生成完整的技术标目录结构,确保覆盖所有技术评分要点。"""这本身没有错,但对缓存来说粒度不够。改成多消息结构后,稳定上下文被拆成独立消息,前缀可以复用:
def generate_outline_prompt(overview: str, requirements: str) -> List[Dict[str, str]]: return [ {'role': 'system', 'content': _build_outline_system_prompt()}, {'role': 'user', 'content': f'项目概述:\n{overview}'}, {'role': 'user', 'content': f'技术评分要求:\n{requirements}'}, { 'role': 'user', 'content': '请生成完整的技术标目录结构,确保覆盖所有技术评分要点。', }, ]如果结合用户旧目录一起生成,也照样拆开:
def generate_outline_with_old_prompt( overview: str, requirements: str, old_outline: str | None, ) -> List[Dict[str, str]]: return [ {'role': 'system', 'content': _build_outline_system_prompt()}, {'role': 'user', 'content': f'项目概述:\n{overview}'}, {'role': 'user', 'content': f'技术评分要求:\n{requirements}'}, {'role': 'user', 'content': f'用户自己编写的目录:\n{old_outline or ""}'}, { 'role': 'user', 'content': '请在满足技术评分要求的前提下,充分结合用户自己编写的目录,生成完整的技术标目录结构。', }, ]好处很直接:
- 项目概述和评分要求变成了稳定的共享上下文;
- 普通目录生成和旧目录扩写两种请求也能共享前面的长前缀。
仓库实证:目录生成的持久会话与工作区文件
当前仓库的目录生成走的是"持久 Agent + 工作区文件"路线(任务标识定义在 outlineGenerationAgentV2Config.cjs,流程在 outlineGenerationTaskV2.cjs)。相比一次性把overview/requirements塞进单个 prompt,它把项目概述、技术评分要求、目录树等材料写成工作区文件,让模型在同一会话内反复读取;重新生成目录时,同一会话内已读取过的上下文天然具备前缀复用条件。这与"稳定上下文尽量前置、可复用"的原则殊途同归——一个在消息层面做前缀拆分,一个在会话/工作区层面做上下文复用。
四、消耗最大的正文编写,必须优化
正文生成是整个系统里调用次数最多的功能:一个项目几十个叶子章节很正常,每个章节都要发一次请求。于是以下内容会被反复传很多遍:
- 同一份
project_overview; - 相同的
parent_chapters(上级章节链); - 高度重叠的
sibling_chapters(同级章节); - 一模一样的正文写作规则。
旧写法是把这些内容全拼进一个大user_prompt:
user_prompt = f"""请为以下标书章节生成具体内容: {context_info} 当前章节信息: 章节ID: {chapter_id} 章节标题: {chapter_title} 章节描述: {chapter_description} 请根据项目概述信息和上述章节层级关系,生成详细的专业内容..."""现在改成了分层消息结构——稳定材料一条消息、章节差异最后一条消息:
def build_chapter_content_messages( chapter: Dict[str, Any], parent_chapters: List[Dict[str, Any]] | None = None, sibling_chapters: List[Dict[str, Any]] | None = None, project_overview: str = '', ) -> List[Dict[str, str]]: messages = [ {'role': 'system', 'content': system_prompt}, ] if project_overview.strip(): messages.append( {'role': 'user', 'content': f'项目概述信息:\n{project_overview}'} ) if parent_chapters: messages.append({'role': 'user', 'content': parent_context}) if sibling_chapters: messages.append({'role': 'user', 'content': sibling_context}) messages.append( { 'role': 'user', 'content': f'''请为以下标书章节生成具体内容: 当前章节信息: 章节ID: {chapter_id} 章节标题: {chapter_title} 章节描述: {chapter_description} 请根据项目概述信息和上述章节层级关系,生成详细的专业内容...''', } ) return messages这一步的价值特别大:同一父章节下的多个叶子节点,往往project_overview一样、parent_chapters一样、sibling_chapters高度接近,真正变化最大的只是最后那条"当前章节任务"。正文生成不仅请求多,而且重复上下文特别长,所以它是最值得做缓存优化的环节。
仓库实证:正文生成的"全轮公共前缀 + 并发预热"
当前仓库的正文生成实现(contentGenerationAgent.cjs,第 397-443 行)把这一原则贯彻到了极致,代码注释直接点明了设计意图:
// 全轮相同的规则和材料排在本节内容之前,便于模型服务复用请求前缀缓存。 const system = `${writingInstructions(...)}\n\n本次事实处理要求:\n...`; const sharedInput = `项目概述: ${overview} 全局事实设定(完整内容): ${facts} 字数要求: ${decisions.word_requirements} 用户额外要求: ${decisions.user_requirement} 受限 HTML 模板: ${template} 所选模板配置: ${config}`;system只装全轮不变的规则:写作指令、全局事实处理要求、正文规则、配图类型对照表、配图要求、写作执行要求;sharedInput只装全轮不变的输入材料:项目概述、全局事实、字数要求、用户额外要求、受限 HTML 模板、模板配置;- 每节差异压到最后一小段:只有"本节编排决策、本节配图安排与补充要求、补充参考资料摘录"(第 432-441 行)跟着单条请求变化。
并发控制也做了缓存配套:contentGenerationAgent.cjs第 419 行在派发并发小节前调用warmSharedPrefix(定义在 contentGenerationPrefixWarmup.cjs)。该函数专门解决一个隐蔽问题——并发请求同时到达时,彼此都还没来得及写入缓存,谁都用不上谁。它的做法是:
// 并发请求同时到达时互相用不上缓存;先用只含公共前缀的短请求写入缓存,失败不影响后续并发。 async function warmSharedPrefix({ aiService, system, sharedInput, signal, onActivity, logTitle, label }) { onActivity?.({ message: `正在预热${label}缓存` }); try { await aiService.chat({ signal, logTitle, output_token_limit: 1, messages: [{ role: 'system', content: system }, { role: 'user', content: sharedInput }] }); // 部分服务在请求结束后才异步构建前缀缓存,稍候再放开并发。 await delay(PREFIX_WARMUP_SETTLE_MS, undefined, { signal }); } catch (error) { signal.throwIfAborted(); onActivity?.({ message: `${label}缓存预热失败,直接并发:${error.message}` }); } }预热请求只包含system + sharedInput这段公共前缀,并且把output_token_limit压到 1,几乎不产生输出成本;随后等待PREFIX_WARMUP_SETTLE_MS = 1500毫秒让服务端完成缓存构建,再放开并发。预热失败也不阻塞主流程,直接进入并发。这套"先小成本写缓存、再放大并发"的策略,是把前缀缓存从"理论收益"变成"工程收益"的关键一环。
顺带一提,仓库对缓存命中是有"仪表盘"的:代理层 agentOpenAiProxy.cjs 的createPiSseUsage(第 454-487 行)会把服务商返回的cached_tokens归一化写入prompt_tokens_details.cached_tokens,供上层用量统计与成本核算使用——这意味着缓存省下的钱不是玄学,而是可以被量化观测的指标。
五、总结:四条可复用的提示词组织原则
这次改造最有价值的不是某一段具体提示词,而是下面这套规则。以后只要遇到高频 AI 任务,都可以优先按这个思路组织提示词:
1.system只放稳定规则
不要把这次请求特有的差异塞进system。system更适合放:
- 通用角色;
- 通用写作规范;
- 通用输出要求。
2. 最大、最稳定的上下文尽量前置
比如:
- 招标文件全文;
- 项目概述;
- 技术评分要求;
- 目录树;
- 上级章节链。
这些内容越稳定、越长、越可能被重复利用,就越应该尽量往前放。
3. 任务差异尽量放最后
比如:
- 提取项目概述;
- 提取技术评分要求;
- 生成目录;
- 生成 3.2.1 章节正文。
这些都是每次请求最容易变化的内容,应该尽量放到消息列表末尾。
4. 同一份数据的组织格式要保持稳定
缓存看的是前缀,不只是"意思差不多"。所以这些细节都要保持一致:
- 标题写法一致;
- 换行数量一致;
- 列表顺序一致;
- 不要一会儿
strip()一会儿不strip(); - 不要把随机信息、时间戳塞进共享上下文。
这些看起来都是小事,但对缓存命中影响非常直接。
六、实测结果:输入成本直降 90% 以上
原文档作者使用 OpenRouter 上的gemini-2.5-flash模型做了一次标书解析测试:从几万字的招标文件中解析项目概述和技术评分要求。测试环境如下:
- 服务商:OpenRouter;
- 模型:
google/gemini-2.5-flash; - 请求地址:
https://openrouter.ai/api/v1/chat/completions; - 测试方式:招标文件解析。
第一次请求(写入缓存)的关键日志字段:
request_id: c405ec7f755549629e8a47c04d5b2633 prompt_tokens:19349 cached_tokens: 0 upstream_inference_prompt_cost: 0.0058047第一次请求相当于把缓存写进去,所以cached_tokens为 0,输入提示词费用按正常价格计算。
第二次请求(命中缓存)的关键日志字段:
request_id: b4cfd26bdef74b9c941263a96b692cea prompt_tokens:19737 cached_tokens: 19442 upstream_inference_prompt_cost: 0.00067176对比两次日志:
- 第二次请求已成功命中缓存;
- 命中的缓存 token 数达到
19442; - 输入提示词费用从
0.0058047降到0.00067176。
也就是说,同样是一份很长的招标文件上下文,第二次请求的输入成本降到了第一次的大约九分之一,接近 10 倍差距。这证明了缓存优化不是玄学、不是"可能会省一点"——只要以下条件满足,省下的钱可以直接从日志里看到:
- 前缀稳定;
- 大文本够长;
- 第二次请求跟得足够快;
- 模型和服务商本身支持缓存。
七、延伸阅读
本文讨论的核心代码与流程都可以在当前仓库中继续深挖:
- bidAnalysisTask.cjs:招标文件解析的稳定前缀、任务拆分与 5 秒缓存预热等待;
- contentGenerationPrefixWarmup.cjs:并发前的小成本前缀预热实现;
- contentGenerationAgent.cjs:正文生成"全轮公共前缀 + 每节尾部差异"的消息编排;
- outlineGenerationTaskV2.cjs 与 outlineGenerationAgentV2Config.cjs:目录生成的持久会话与工作区复用;
- agentOpenAiProxy.cjs:
cached_tokens的用量归一化,用于观测缓存命中。
提示词顺序优化是投入产出比极高的一类改造:不改变模型、不改变输出质量、不需要换服务商,只需要调整消息的排列结构和拼接格式,就能让高频任务持续吃到前缀缓存。对于标书这类"大上下文 + 高频重复请求"的场景,它带来的成本收益是直接且可量化的。
- AI 应用
- AI 写作
- 人工智能
- AI Agent
- 企业应用
【免费下载链接】OpenBidKit_Yibiao
开箱即用的AI标书编写工具,标书AI生成工具,投标工具箱、知识库、标书查重、废标项检查,完全开源免费,欢迎使用
相关推荐
Emacs-IPython-Notebook中的代码补全与调试:提升Python开发体验
Emacs IPython Notebook中的代码补全与调试:提升Python开发体验 Emacs IPython Notebook(EIN)是一款强大的Ju
10倍性能提升:Nginx缓存命中率监控与优化实战指南
10倍性能提升:Nginx缓存命中率监控与优化实战指南 Nginx作为高性能的HTTP和反向代理服务器,其缓存机制是提升网站性能的关键。本文将详细介绍如何监控和
后端API网关负载均衡网络提升10倍性能:Doctrine Annotations缓存优化实战
提升10倍性能:Doctrine Annotations缓存优化实战 你是否在开发大型PHP应用时遇到过启动缓慢的问题?尤其是使用Doctrine ORM或Sy
后端
创作声明:本文部分内容由AI辅助生成(AIGC),仅供参考