标书智能体(四)——提示词顺序优化,让缓存命中,输入成本直降10倍
今天是第四期,聊一个做 AI 应用大家都特别关心,但容易走错方向的问题:
成本和提示词顺序的关系
提示词的组织顺序如果写对了,除了结果质量,连成本都能一起降下来。
前面三期,我们已经把标书智能体的主流程基本打通了:
- 解析招标文件,提取项目概述和技术评分要求。
- 根据解析结果生成技术标提纲。
- 根据提纲逐章生成正文。
做到这一步,其实系统已经可以用了。
但真用起来就会发现,标书这个场景和普通聊天不一样,最大的成本不一定在输出,而在输入。
一份招标文件动辄几万字,项目概述和评分要求也不短。正文生成时,同一份项目概述、同一套章节层级信息,还会被反复传给模型。
而节约成本最好的办法,不是精简提示词,而是调整提示词的顺序!
如果提示词结构写得不对,服务商的缓存能力就很难吃到,钱就白白花出去了。
一、为什么吃不到缓存
很多大模型服务商都支持 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一样 - 只有最后一条任务说明不一样
这个结构,就比原来那种“先写不同任务,再塞同一份全文”的方式,更容易命中缓存。
三、提纲编写部分优化
第二期讲提纲生成的时候,重点主要放在 JSON 格式和目录质量上。
但从成本角度看,提纲生成也有一样的问题。
同一个项目里,用户经常会多点几次“重新生成目录”。
那同一份 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': '请在满足技术评分要求的前提下,充分结合用户自己编写的目录,生成完整的技术标目录结构。',
},
]
这么写的好处很直接:
- 项目概述和评分要求变成了稳定的共享上下文
- 普通目录生成和旧目录扩写,也能共享前面一部分前缀
四、消耗最大的正文编写,必须优化
第三期讲正文生成时,我们已经解决了两个最核心的问题:
- 标书太长,不能一次性生成,必须拆成叶子章节逐节写
- 分节写容易重复,所以要把上级章节和同级章节信息一起传给模型
这个思路本身没有问题。
但从缓存的角度再回头看,会发现正文生成这一段才是真正的大头。
因为正文生成是整个系统里调用次数最多的功能。
一个项目几十个叶子章节很正常。每个章节都要发一次请求,那下面这些内容就会被反复传很多遍:
- 同一份
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高度接近- 真正变化最大的,其实只是最后那条当前章节任务
也就是说,正文生成这一块,不仅请求多,而且重复上下文还特别长。
这也是为什么正文生成最值得做缓存优化。
更多推荐
所有评论(0)