这次我们来看一个很有意思的 AI 工程实践项目:让 AI 投标书撰写者拒绝说谎。
标题直译过来就是“让 AI 标书写作工具拒绝撒谎”。在真实业务场景里,投标书涉及企业资质、业绩案例、技术参数、服务承诺,每一行都可能直接影响是否中标,更严重一点说,虚假信息一旦被核实,轻则废标,重则进黑名单。很多 AI 写作文案工具存在一个通病:为了生成流畅内容,模型会“脑补”案例、编造资质、填充不存在的项目业绩。这个项目要解决的就是这个问题——让 AI 在信息不确定时主动拒绝生成,而不是硬编。
从架构上看,这可以拆解为“指令约束 + 事实边界 + 拒绝机制”三部分:通过提示词设计给模型设定行为边界,通过结构化知识库让模型只能引用已验证的材料,通过输出校验和拒绝策略兜底。换句话说,这本质上是一个LLM 应用的可控生成工程问题,涉及 API 调用、RAG 检索、提示词优化、输出校验和合规约束。
这篇文章会围绕这个主题,从核心能力、环境准备、部署启动、功能测试、API 批量任务、性能观察、排查手段到最佳实践,完整带大家走一遍。如果你正在做 AI Agent、AI 文档生成、GPT 应用开发、AI 工程实践相关项目,这篇可以直接收藏。
1. 核心能力速览
| 能力项 | 说明 |
|---|---|
| 项目类型 | AI 文档生成 / 可控文本生成 / RAG 检索增强 |
| 核心目标 | 让 AI 标书撰写工具拒绝生成不实信息 |
| 主要功能 | 真实材料召回、事实核验、拒绝生成机制、输出标注来源 |
| 关键技术 | LLM API、RAG、提示词工程、结构化知识库、输出校验 |
| 推荐硬件 | 使用云端 API 时无需本地 GPU;本地部署开源模型时建议 16G 以上显存 |
| 显存占用 | 取决于模型选择;使用 API 模式几乎不占本地显存 |
| 支持平台 | Windows / Linux / macOS,Python 环境 |
| 启动方式 | 命令行启动 / API 服务模式 |
| 是否支持 API | 支持,可集成到业务系统 |
| 是否支持批量任务 | 支持,可设计批量标书章节生成和校验队列 |
| 适合场景 | 投标书初稿、审计报告初稿、合规文档、需要事实约束的文本生成 |
从材料看,该项目的重点不是模型本身,而是工程层的“诚实性控制”。它不是一个 UI 一键工具,更像一套可以接到任意 LLM 的生成控制中间层。理解了这一点,后续环境准备和部署思路就清楚了。
2. 适用场景与使用边界
2.1 适合谁用
- 做标书代写工具、文档自动化 SaaS、企业知识库问答的开发者。
- 给银行、政企、工程类项目做合规文本生成的团队。
- 做 AI 应用开发、AI Agent 工程实践的技术人员。
- 需要限制 LLM “胡说八道”的文档场景。
2.2 能解决的问题
- 模型编造公司业绩、项目案例、人员资质。
- 模型在信息缺失时强行生成“看似合理”的表述。
- 引用过期的政策法规、错误的技术参数。
- 多段文本之间事实矛盾,例如前一章写 A 公司有 100 人,后一章写成 200 人。
2.3 不适合什么场景
- 纯创意写作、营销文案润色,这类场景不需要严格事实约束。
- 需要模型基于“猜”进行头脑风暴的场景。
- 完全没有可靠材料的场景——如果库里没有内容,AI 只能拒绝生成,产出会非常保守。
2.4 合规与隐私边界
这个项目直接涉及企业资质、商业业绩、个人证书等敏感信息。使用过程中必须注意:
- 企业资质、业绩、证书等信息需获得授权后才可录入知识库。
- 生成的标书内容属于商务文档,涉及商业机密时不应上传到公共模型 API,建议私有化部署或使用合规 API。
- 建立“可追溯”机制,任何由 AI 生成的事实性陈述都必须能回溯到原始材料。
- 不要用该项目生成涉及虚假授权、仿冒签名、伪造证件的文本。
3. 环境准备与前置条件
从实践角度看,这个项目建议按两条路准备环境:
- API 模式:本地无需 GPU,调用 GPT、Claude、通义千问、文心一言等大模型接口。
- 本地模型模式:需要一张至少 16G 显存的显卡,部署 Qwen、Llama、ChatGLM 等开源模型。
3.1 操作系统与 Python 环境
建议使用 Linux 服务器或 Windows 10/11 搭配 WSL2。Python 版本建议 3.10 到 3.12,低于 3.9 的话很多依赖包可能不兼容,高于 3.12 时部分本地框架可能还没有适配。
# 创建虚拟环境 python3.11 -m venv venv source venv/bin/activate # 或者 Windows # venv\Scripts\activate3.2 依赖库
项目需要以下核心依赖,实际版本号以项目 requirements 为准,安装时不要盲目用最新版本:
pip install openai langchain chromadb pydantic python-dotenv rapidfuzz如果走本地模型路线,还需要追加:
pip install transformers torch sentencepiece accelerate这里多说一句:openai库现在不只服务 OpenAI 官方接口,国内的大模型服务商基本都兼容 OpenAI 协议,只需要改 base_url 和 api_key,这大大降低了后续对接成本。
3.3 模型 API Key
以 API 模式为例,需要在环境变量里配置模型服务密钥:
export LLM_API_KEY="your-api-key" export LLM_BASE_URL="https://api.your-model-service.com/v1" export LLM_MODEL="your-model-name"为了安全,不要把密钥写到代码里,更不要提交到 Git 仓库。
3.4 磁盘与数据准备
标书知识库需要保存企业资质、过往业绩、技术方案、人员证书等文件,建议:
- 文本格式:PDF、Word、Markdown、TXT。
- 每个文档建议控制在 30 页以内,超长文档先拆成章节。
- 预留至少 5G 空间用于向量数据库和临时缓存。
3.5 端口检查
如果要以 API 服务方式启动,建议使用 8000 或 8080 端口,启动前先检查:
lsof -i :8000 # 如果有输出,说明端口被占用,需要换端口或者杀掉旧进程4. 安装部署与启动方式
4.1 代码结构规划
建议按以下目录组织项目,这样后续做批量任务时不会乱:
bid-writer/ ├── docs/ # 原始标书材料 │ ├── company_profile.md │ ├── performance_cases.md │ └── qualifications.md ├── storage/ # 向量数据库和索引 ├── app/ │ ├── ingest.py # 材料入库 │ ├── generate.py # 生成逻辑 │ ├── verify.py # 事实校验 │ └── server.py # API 服务 ├── config/ │ └── settings.yaml └── requirements.txt4.2 材料入库
把原始标书材料拆成适合检索的块,写入向量数据库。这里使用 LangChain 加 Chroma 做演示:
from langchain.document_loaders import TextLoader from langchain.text_splitter import RecursiveCharacterTextSplitter from langchain.embeddings import OpenAIEmbeddings from langchain.vectorstores import Chroma # 加载文档 loader = TextLoader("docs/company_profile.md") documents = loader.load() # 拆分文本,块大小 500,重叠 50 text_splitter = RecursiveCharacterTextSplitter( chunk_size=500, chunk_overlap=50 ) texts = text_splitter.split_documents(documents) # 构建向量库 embeddings = OpenAIEmbeddings() vectordb = Chroma.from_documents( documents=texts, embedding=embeddings, persist_directory="./storage/chroma_db" ) vectordb.persist()注意:实际项目中,公司资质和业绩案例通常来自 PDF 或 Word,需要先用 pdfplumber、unstructured 或 docx 库做格式解析,再进入上面流程。
4.3 核心生成逻辑:约束 + 检索 + 拒绝
这一步是项目的灵魂。生成标书内容之前,先执行检索,从知识库中找出与问题相关的材料;然后基于这些材料生成内容;最后再做一个“事实核验”。如果核验不过,就拒绝输出并返回原因。
下面是一个简化示例:
from langchain.vectorstores import Chroma from langchain.embeddings import OpenAIEmbeddings from langchain.llms import OpenAI SYSTEM_PROMPT = """ 你是一个投标书撰写助手。你的任务是基于给定的企业真实材料撰写标书内容。 你必须遵守以下规则: 1. 只能使用提供的材料中明确存在的信息。 2. 如果材料中没有相关信息,必须明确回答“没有找到相关材料,无法生成该部分内容”。 3. 禁止编造公司资质、业绩案例、人员证书、技术参数。 4. 涉及数字、日期、金额时必须与材料完全一致。 5. 每段内容的末尾,列出该段引用来自哪些材料文件。 """ def retrieve_similar(query: str, k: int = 5): embeddings = OpenAIEmbeddings() vectordb = Chroma( embedding_function=embeddings, persist_directory="./storage/chroma_db" ) docs = vectordb.similarity_search(query, k=k) return "\n\n".join([d.page_content for d in docs]) def generate_bid_content(query: str): context = retrieve_similar(query) messages = [ {"role": "system", "content": SYSTEM_PROMPT}, {"role": "user", "content": f"材料:\n{context}\n\n写作要求:{query}"} ] response = openai.ChatCompletion.create( model="gpt-4", messages=messages, temperature=0.2 ) return response["choices"][0]["message"]["content"]这里有几个关键点:
temperature=0.2,降低随机性,减少模型自由发挥的空间。- 系统提示词里写清楚了“禁止编造”和“拒绝生成”的行为策略。
- 把所有检索到的材料放进上下文,让模型只能在这个范围内作答。
4.4 事实校验与拒绝机制
生成之后,不能直接信任输出。需要再做一道“回查”:
- 把输出中的每个关键事实性断言,映射回原始材料。
- 如果找不到对应文本,就认为该断言没有依据。
- 如果一段输出里存在超过 1 条无依据断言,整段拒绝。
import re from rapidfuzz import fuzz def verify_claims(generated_text: str, source_materials: list) -> dict: # 提取输出中的数字、公司名、日期作为关键断言 numbers = re.findall(r"\d+(?:\.\d+)?%?|[\d,]+人|[\d,]+万元", generated_text) unverified = [] for num in numbers: matched = False for material in source_materials: if num in material or fuzz.partial_ratio(num, material) > 85: matched = True break if not matched: unverified.append(num) return { "unverified": unverified, "safe": len(unverified) == 0 } # 返回值中的 safe 如果为 False,就拒绝生成该段在实际项目中,校验策略需要更强:可以把“断言抽取”从正则升级为基于 NER 的实体识别,把“匹配”升级为向量召回比对。这里只展示简化逻辑,原理是一致的。
4.5 启动 API 服务
为了方便接入业务系统,可以包装成一个 FastAPI 服务:
from fastapi import FastAPI from pydantic import BaseModel app = FastAPI() class BidRequest(BaseModel): section: str query: str class BidResponse(BaseModel): content: str refused: bool reason: str @app.post("/api/bid/generate", response_model=BidResponse) def generate_bid(req: BidRequest): content = generate_bid_content(req.query) # 这里插入事实校验逻辑 return BidResponse( content=content if not refused else "", refused=refused, reason="没有找到相关材料,无法生成该部分内容" if refused else "" )启动命令:
uvicorn app.server:app --host 0.0.0.0 --port 80004.6 启动后验证
启动服务后,用 curl 做一次最简单的连通性测试:
curl http://localhost:8000/docs正常情况下,浏览器访问/docs可以看到 FastAPI 自动生成的 Swagger 调试页面。这是排查启动问题最快的手段——如果/docs能打开,说明服务整体起来了。
5. 功能测试与效果验证
5.1 测试一:基于真实材料的生成
先在知识库存入一段真实的公司业绩材料:
"XX科技有限公司,成立于2015年,注册资金5000万元,拥有系统集成一级资质。 2021年中标XX省政务云平台建设项目,合同金额8200万元。 2022年中标XX市智慧交通项目,合同金额4600万元。"然后发起生成请求:
import requests payload = { "section": "业绩案例", "query": "请描述一下公司在政务领域的成功案例。" } resp = requests.post("http://localhost:8000/api/bid/generate", json=payload) print(resp.json())预期结果:
- 输出包含“XX省政务云平台建设项目”和“8200万元”。
- 输出末尾标注了来源文件。
- 没有额外编造“2023 年中标 XX 项目”。
判断成功的标准:输出里的数字和项目名称都能在原材料中找到。
5.2 测试二:缺失信息时的拒绝行为
现在询问一个知识库中没有的信息:
payload = { "section": "人员资质", "query": "公司是否有 CMMI5 认证?" } resp = requests.post("http://localhost:8000/api/bid/generate", json=payload) print(resp.json())预期结果:
- 返回内容为“没有找到相关材料,无法生成该部分内容”。
refused字段为true。- 不会生成“公司拥有 CMMI5 认证”这类猜测性文本。
这一步是整个项目最关键的验收项。如果模型在这类输入下仍然强行输出,说明拒绝机制失效,需要检查系统提示词是否被忽略,或者上下文是否为空导致模型自行发挥。
5.3 测试三:数字准确性压力测试
把多个相似数字混在上下文中,测试模型是否忠实引用:
材料 A:2021 年合同金额 8200 万元。 材料 B:2022 年合同金额 4600 万元。 材料 C:2023 年合同金额 9800 万元。请求:“列出公司近三年每年度合同金额。”
预期输出必须严格按年份对应金额,不能把 8200 写成 2022 年,也不能把 4600 写成 2021 年。
5.4 测试四:批量生成与一致性
批量生成多个章节,检查各章节之间是否有冲突。例如:
- 第一章写“公司成立于 2015 年”。
- 第五章不能变成“公司成立于 2016 年”。
批量任务通常需要按顺序执行,并把前文的结果作为后文的事实约束。
6. 接口 API 与批量任务
这个项目最终的价值是接入业务系统,所以 API 设计和批量任务能力必须单独讨论。
6.1 API 参数设计建议
| 字段 | 类型 | 说明 |
|---|---|---|
| section | string | 标书章节名称 |
| query | string | 写作要求 |
| max_tokens | int | 最大生成长度 |
| temperature | float | 采样温度,建议 0.1-0.3 |
| source_files | list | 限定使用哪几个材料文件 |
| strict_mode | bool | 严格模式,开启后拒绝一切无依据内容 |
6.2 批量任务设计
批量生成标书章节时,建议使用异步任务队列,不要直接同步调用 LLM。原因很简单:标书通常有几十个章节,每个章节要经过“检索 + 生成 + 校验”三个环节,同步调用总耗时可能达到 5-10 分钟,很容易触发 HTTP 超时。
推荐使用 Redis + Celery 或简单的 Asyncio 队列:
from fastapi import BackgroundTasks from celery import Celery celery_app = Celery("bid_tasks", broker="redis://localhost:6379/0") @celery_app.task def generate_section(section: str, query: str): content = generate_bid_content(query) return {"section": section, "content": content} @app.post("/api/bid/batch") async def create_batch(requests: list[BidRequest]): task_ids = [] for req in requests: result = generate_section.delay(req.section, req.query) task_ids.append(result.id) return {"task_ids": task_ids}业务侧轮询任务状态时,建议实现失败重试。LLM 调用经常因为限流、网络超时等原因失败,重试逻辑要加上指数退避。
6.3 curl 调用示例
curl -X POST http://localhost:8000/api/bid/generate \ -H "Content-Type: application/json" \ -d '{"section": "技术方案", "query": "描述系统架构设计"}'6.4 返回结果示例
{ "content": "系统采用微服务架构,包含 API 网关、业务服务层、数据存储层。数据存储层采用分布式数据库,支撑高并发访问场景。\n\n引用来源:docs/technical_plan.md", "refused": false, "reason": "" }7. 资源占用与性能观察
7.1 显存占用观察方法
如果使用本地模型,显存占用取决于模型参数量:
- 7B 模型量化后大约需要 6-8G 显存。
- 14B 模型量化后大约需要 12-16G 显存。
- 直接用 ChatGPT、通义等 API 则不占用本地显存。
显存观测使用 nvidia-smi:
watch -n 1 nvidia-smi7.2 性能瓶颈分析
这个项目的性能瓶颈通常不在模型本身,而在三个环节:
- 材料解析:PDF 解析最耗时,建议预解析为 Markdown 或纯文本。
- 向量检索:数据库量大时检索时间上升,需要给向量库加索引。
- 输出校验:正则和模糊匹配速度快,但如果引入大模型二轮校验,耗时可能翻倍。
从工程经验看,API 模式的生成速度瓶颈主要受限于模型响应速度,而本地模式受限于显存带宽和批量大小。
7.3 降低延迟的建议
- 把材料入库和生成分离,不要每次请求都重新构建向量库。
- 对常用标书章节做缓存,相同 query 直接命中。
- 批量任务需要控制并发数,一般 3-5 个并发比较稳妥,避免触发限流。
- 严格模式会显著降低输出率,但如果标书要求“每个数字都溯源”,这一步不能省。
8. 常见问题与排查方法
| 问题现象 | 可能原因 | 排查方式 | 解决方案 |
|---|---|---|---|
启动后/docs打不开 | 端口被占用或服务未启动 | 检查日志和端口占用 | 更换端口或杀掉旧进程 |
| 模型总是编造业绩 | 上下文为空或系统提示词被忽略 | 打印最终发给模型的完整 prompt | 检查检索结果是否为空;强化系统提示词 |
| 检索不到相关内容 | 文档入库未完成或向量库路径错误 | 查看入库日志,检查向量库目录 | 重新执行入库命令 |
| 生成结果数字错误 | 上下文过长导致模型注意力分散 | 检查检索片段是否混入相似数字 | 降低 chunk_size,或增加数字校验 |
| 批量任务全部超时 | 并发过大触发限流 | 查看模型服务日志 | 降低并发数,增加重试 |
| 安装依赖失败 | Python 版本不兼容 | 查看报错信息中的版本要求 | 切换到 3.10-3.12 环境 |
| PDF 材料解析乱码 | PDF 是扫描件 | 检查文件是否为图片型 PDF | 先接入 OCR 识别再入库 |
| 拒绝生成比例过高 | 知识库内容不足 | 检查库内材料覆盖范围 | 补充高质量材料,或调整检索阈值 |
| 中文匹配准确率低 | 使用英文分词器 | 检查向量检索的分词方式 | 切换为中文分词器或向量模型 |
| API Key 报 401 | 密钥无效或服务商不匹配 | 检查 base_url 和 api_key 是否对应 | 重新配置环境变量 |
8.1 排查流程建议
最实用的排查顺序是“从输入到输出”:
- 先打印检索结果,确认模型能看到什么。
- 再打印完整 prompt,确认系统约束是否生效。
- 最后检查校验模块的日志,确认哪些断言被判定为无依据。
90% 的“AI 胡说八道”问题,在第一步和第二步就能定位。
9. 最佳实践与使用建议
9.1 关于系统提示词
系统提示词是这个项目最重要的“代码”。建议采用“能力定义 + 禁令列表 + 行为示例”三段式写法:
能力定义:你是投标书撰写助手,只能基于材料撰写。 禁令列表: - 禁止编造公司资质。 - 禁止编造过往业绩。 - 禁止修改合同金额、日期、项目名称。 - 禁止在无材料时生成推测性内容。 行为示例: 用户问:公司是否有 CMMI5 认证? 材料中没有相关信息时,回答:“没有找到相关材料,无法生成该部分内容。”9.2 关于知识库建设
- 材料一定要做去重和版本管理,同一份文件有多个版本时,旧版本存在校验 bug 隐患。
- 不要一次性把所有材料塞进上下文,优先检索再拼装,保证信息相关性。
- 定期更新政策法规类材料,过期法规会在标书中造成硬伤。
9.3 关于商用合规
- 标书中的企业资质、业绩案例属于商业事实,不提供来源的 AI 生成文本不能直接使用。
- 建议给每个生成章节附带“来源引用”,便于人工复核。
- 对外发布或投标前,必须由熟悉业务的专人做最终审核,AI 只负责初稿。
9.4 关于批量生成
- 批量任务使用异步队列,每个任务都要有独立日志。
- 任务失败要有重试机制,但重试不能无限循环,一般 3 次以内。
- 严格模式适合批量任务默认开启,宁可拒绝,也不能硬编。
9.5 关于模型选择
- 追求稳定性和事实一致性,优先选择指令跟随能力强的模型。
- 项目早期可以直接用 Python 写“规则 + 向量检索 + LLM 调用”的轻量流程,不要一开始就上复杂框架。
- 后期如果需要在本地私有化部署,再考虑 Qwen、Llama 等开源模型,并用 vLLM 或 Ollama 托管。
10. 总结与下一步
这个项目最值得尝试的点,是把“AI 可控生成”从概念落到了可运行的工程流程:检索真实材料、约束生成范围、校验输出事实、拒绝无依据内容。它本质上是一套“防幻觉”中间层,不依赖单一模型,可以接入任意主流 LLM API,也可以替换为本地开源模型。
最先应该验证的功能是“缺失信息时的拒绝行为”:当你问一个知识库里不存在的问题时,系统是否真的会拒绝,而不是开始编造。这个功能跑通了,整个项目的核心价值就成立了。
最容易踩的坑有两个。第一个是知识库材料没有充分解析就入库,导致检索阶段找不到有效内容;第二个是系统提示词写得不够硬,模型在用户压力下仍然尝试编造。这两个问题都要通过打印完整 prompt 和检索结果来排查。
后续可以继续扩展的方向包括:
- 把“事实校验”升级为“多模型交叉验证”,用另一个模型对生成内容做二次核验。
- 增加文章级别的逻辑一致性检查,防止多个章节之间数据矛盾。
- 对接 Office 文档格式,直接生成符合招标文件格式的 Word 标书。
- 增加人工审核工作流:AI 生成初稿,标记不确定项,人工确认后定稿。
建议收藏备用,尤其是在做 AI 工程实践、AI 文档生成、企业知识库相关项目的同学,这套“检索 + 约束 + 校验 + 拒绝”的闭环可以直接复用到其他场景。