用 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
