用 Python + React 打造一个开源的 AI 写标书智能体~ **完整代码已开源。** 代码很多,文章只放主要代码和提示词,完整代码可以查看开源项目。 Github: https://github.com/FB208/yibiao-simple Gitee: https://gitee.com/yibiao-ai/yibiao-simple 今天是第四期,聊一个做 AI 应用大家都特别关心,但容易走错方向的问题: **成本和提示词顺序的关系** 提示词的组织顺序如果写对了,除了结果质量,连成本都能一起降下来。 前面三期,我们已经把标书智能体的主流程基本打通了: 1. 解析招标文件,提取项目概述和技术评分要求。 2. 根据解析结果生成技术标提纲。 3. 根据提纲逐章生成正文。 做到这一步,其实系统已经可以用了。 但真用起来就会发现,标书这个场景和普通聊天不一样,最大的成本不一定在输出,而在输入。 一份招标文件动辄几万字,项目概述和评分要求也不短。正文生成时,同一份项目概述、同一套章节层级信息,还会被反复传给模型。 而**节约成本最好的办法,不是精简提示词,而是调整提示词的顺序!** 如果提示词结构写得不对,服务商的缓存能力就很难吃到,钱就白白花出去了。 ## 一、为什么吃不到缓存 很多大模型服务商都支持 Prompt Cache,或者有类似的缓存机制。 但缓存不是你以为的那种“只要内容一样就行”。 大多数情况下,它看的是请求前缀。 也就是说,前面那一大段内容是不是稳定,是不是一致,是不是每次都放在同样的位置。 如果你把一份很长的正文,放在提示词后面,而前面先拼了很多每次都不一样的说明,那这份长文本虽然看起来是一样的,服务商也未必把它识别成同一个可复用前缀。 拿文档解析这个功能举例。 我们对同一份招标文件,会做两次分析: 1. 提取项目概述 2. 提取技术评分要求 最开始我的写法是这样的: ```python 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` 是一样的。 问题在于: 1. 两次请求的 `system_prompt` 不一样 2. 两次请求的 `user_prompt` 前缀也不一样 3. 真正占 token 的大文本 `file_content` 被放到了后面 这样一来,服务商看到的不是“同一个长前缀”,而是“两个不一样的请求,后面有一大段内容刚好相同”。 那缓存命中率自然就高不起来。 ## 二、解决方案 这个问题想通以后,改法其实很简单。 原则就一句话: 把大且稳定的上下文放前面,把这次请求的任务差异放最后。 改造后的文档解析提示词变成了这样: ```python 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}, ] ``` 这样改完以后,这两个请求的共同前缀就很清晰了: 1. 第一条 `system` 一样 2. 第二条全文 `user` 一样 3. 只有最后一条任务说明不一样 这个结构,就比原来那种“先写不同任务,再塞同一份全文”的方式,更容易命中缓存。 ## 三、提纲编写部分优化 第二期讲提纲生成的时候,重点主要放在 JSON 格式和目录质量上。 但从成本角度看,提纲生成也有一样的问题。 同一个项目里,用户经常会多点几次“重新生成目录”。 那同一份 `overview` 和 `requirements` 就会被反复发给模型。 旧写法通常是把它们拼成一个大 `user_prompt`: ```python user_prompt = f"""请基于以下项目信息生成标书目录结构: 项目概述: {overview} 技术评分要求: {requirements} 请生成完整的技术标目录结构,确保覆盖所有技术评分要点。""" ``` 这当然没错,但对于缓存来说,不够细。 我们现在改成了多消息结构: ```python 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': '请生成完整的技术标目录结构,确保覆盖所有技术评分要点。', }, ] ``` 如果是结合用户旧目录一起生成,也照样拆开: ```python 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': '请在满足技术评分要求的前提下,充分结合用户自己编写的目录,生成完整的技术标目录结构。', }, ] ``` 这么写的好处很直接: 1. 项目概述和评分要求变成了稳定的共享上下文 2. 普通目录生成和旧目录扩写,也能共享前面一部分前缀 ## 四、消耗最大的正文编写,必须优化 第三期讲正文生成时,我们已经解决了两个最核心的问题: 1. 标书太长,不能一次性生成,必须拆成叶子章节逐节写 2. 分节写容易重复,所以要把上级章节和同级章节信息一起传给模型 这个思路本身没有问题。 但从缓存的角度再回头看,会发现正文生成这一段才是真正的大头。 因为正文生成是整个系统里调用次数最多的功能。 一个项目几十个叶子章节很正常。每个章节都要发一次请求,那下面这些内容就会被反复传很多遍: 1. 同一份 `project_overview` 2. 相同的 `parent_chapters` 3. 高度重叠的 `sibling_chapters` 4. 一模一样的正文写作规则 旧写法是把这些内容全拼进一个大 `user_prompt`: ```python user_prompt = f"""请为以下标书章节生成具体内容: {context_info} 当前章节信息: 章节ID: {chapter_id} 章节标题: {chapter_title} 章节描述: {chapter_description} 请根据项目概述信息和上述章节层级关系,生成详细的专业内容...""" ``` 现在改成了分层消息: ```python 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 ``` 这一步的价值特别大。 因为同一个父章节下面的多个叶子节点,往往都有下面这些共同点: 1. `project_overview` 一样 2. `parent_chapters` 一样 3. `sibling_chapters` 高度接近 4. 真正变化最大的,其实只是最后那条当前章节任务 也就是说,正文生成这一块,不仅请求多,而且重复上下文还特别长。 这也是为什么正文生成最值得做缓存优化。 ## 五、总结 这次改完以后,我觉得最重要的不是某一段具体提示词,而是下面这套规则。 以后只要是高频 AI 任务,都可以优先按这个思路来组织提示词: ### 1. `system` 只放稳定规则 不要把这次请求特有的差异塞进 `system`。 `system` 更适合放: 1. 通用角色 2. 通用写作规范 3. 通用输出要求 ### 2. 最大、最稳定的上下文尽量前置 比如: 1. 招标文件全文 2. 项目概述 3. 技术评分要求 4. 目录树 5. 上级章节链 这些内容越稳定、越长、越可能重复利用,就越应该尽量往前放。 ### 3. 任务差异尽量放最后 比如: 1. 提取项目概述 2. 提取技术评分要求 3. 生成目录 4. 生成 3.2.1 章节正文 这些都是每次请求最容易变化的内容,应该尽量放到最后。 ### 4. 同一份数据的组织格式要保持稳定 缓存看的是前缀,不只是“意思差不多”。 所以这些细节都要保持一致: 1. 标题写法一致 2. 换行数量一致 3. 列表顺序一致 4. 不要一会儿 `strip()` 一会儿不 `strip()` 5. 不要把随机信息、时间戳塞进共享上下文 这些看起来都是小事,但对缓存命中影响非常直接。 ## 六、实测结果 我用OpenRouter上的gemini-2.5-flash模型做了一次标书解析的测试,从几万字的招标文件中解析项目概述和技术评分要求,很明显可以看出第一轮请求是正常的,第二轮请求的输入提示词,基本都吃到了缓存,成本直降90%以上。 测试环境如下: 1. 服务商:OpenRouter 2. 模型:`google/gemini-2.5-flash` 3. 请求地址:`https://openrouter.ai/api/v1/chat/completions` 4. 测试方式:招标文件解析 第一次请求日志里的关键字段是: ```text request_id: c405ec7f755549629e8a47c04d5b2633 prompt_tokens:19349 cached_tokens: 0 upstream_inference_prompt_cost: 0.0058047 ``` 第一次请求相当于把缓存写进去,所以 `cached_tokens` 为 0,输入提示词费用按正常价格计算。 第二次请求日志里的关键字段变成了: ```text request_id: b4cfd26bdef74b9c941263a96b692cea prompt_tokens:19737 cached_tokens: 19442 upstream_inference_prompt_cost: 0.00067176 ``` 这个结果就很说明问题了: 1. 第二次请求已经成功命中了缓存 2. 命中的缓存 token 数达到了 `19442` 3. 输入提示词费用从 `0.0058047` 降到了 `0.00067176` 也就是说,同样是一份很长的招标文件上下文,第二次请求的输入成本已经降到了第一次的大约九分之一,基本就是接近 10 倍的差距。 这一点特别重要。因为它说明了,缓存优化不是玄学,也不是“可能会省一点”。 只要下面几个条件满足: 1. 前缀稳定 2. 大文本够长 3. 第二次请求跟得足够快 4. 模型和服务商本身支持缓存 那它省下来的钱,是可以直接从日志里看到的。 ## 完整代码已开源 Github: https://github.com/FB208/yibiao-simple Gitee: https://gitee.com/yibiao-ai/yibiao-simple