用 AI 写工程标书,死结不在生成,在「敢用」——一个开源项目的降幻觉实录

0 阅读12分钟

封面

一、先讲个场景,你就知道问题在哪了

我拿一份真实的招标文件,加上一份 40 万字的历史施组,丢给 AI 让它写技术标。

它写出来了。章节结构对、专业术语对、行文语气也对——看起来像那么回事

然后我盯着屏幕,一个字都不敢用

原因特别朴素:

我不知道哪里是它编的。

一本施工组织设计几十万字。摆在面前的选择只有两个——要么相信它(我不敢),要么逐字核对(那我不如自己写)。

生成提速 10 倍,核对也要花 10 倍时间。这笔账算下来等于白干。

这就是「AI 写标书」这件事真正的死结。它跟模型强不强关系不大——今天任何一个头部模型都能写出通顺的施组。卡点在后面那一步:你敢不敢签上名字交出去。

所以这个项目(BidCraft 标书匠Apache-2.0,完全免费开源,无付费墙也没有企业版)从一开始就不是奔着「一键生成」去的,而是奔着解决这个死结去的。

工作台

演示项目是虚构的(云岭至江口高速公路 YJ-3 合同段),不含任何真实工程资料,下面几张截图同理。


二、我换掉的那个标准

一开始我的目标是:让它尽量别写错。

后来发现这目标是错的。AI 写几十万字专业文档,在可预见的未来都不可能做到「全部正确、人不用看」。追这个目标,只会得到一堆花哨但不敢用的功能。

所以我把它换掉了:

别追求「不错」,追求「错了你能一眼看见」。

换完之后,衡量标准就变得非常具体,只有一句话:

打开生成的正文,需要我动手改的地方有多少。

下面所有做法,都只服务于这一件事。而它们分布在三个时间点上——写之前、写的时候、写之后。这篇文章就按这个顺序讲。


三、写之前:把数字从正文里拆出来

这是所有措施里最重要的一条,因为它解决的不是「幻觉」,是一个更容易被忽略的问题。

问题的真相

同一份施组里,「总工期」这个数字可能出现在十几个地方。

如果每次都让模型从原文里「读」一遍这个数字——那么这十几个地方,就可能出现十几个版本:26 个月780 天2026 年 3 月至 2028 年 4 月……

注意,这不是幻觉。这是「每次重新理解」必然导致的不一致。

模型每次都很忠实,它只是每次都在重新读、重新理解。你怪不到它头上。

做法:数字不进正文,进一张表

所以我把这类数字全部抽出来,单独存一张 global_fact

字段作用
fact_key / fact_value / unit事实本身(如 总工期 = 26 个月
source_file / source_location出处:哪份文件、哪一节/哪一页
confidence抽取置信度
applicable_lots适用范围(全线通用 / 指定标段)
category分类(工期 / 人员 / 机械 / 造价 / 地质 / 结构…)
status / edited / version状态、是否人工改过、版本号

生成正文时,全部事实作为权威数据一股脑注入,模型的指令只有一句:数字必须来自事实表或本项目文件原文。

有三个设计点我认为很关键,都不是为了「更智能」,而是为了「更可信」:

① 每条数字都带出处。 不是「AI 说的」,是「招标文件 · 第一章 招标公告」。核对的时候你查的是出处,不是一个置信度分数。

② 人工改过的,重抽取时不会被覆盖。 改过的条目 edited=1,AI 后续重跑保留你的值。否则你辛苦改一遍,下次重新解析全被冲掉——那没人愿意改。

③ 值变了,旧版本标 expired、版本号 +1。 历史可追溯。谁在什么时候把 26 个月改成了 30 个月,查得到。

全局参数

左上「全局参数」抽屉:每条事实都带出处,可编辑、可删除,可按标段筛选。

这条措施的价值在于:它把「不确定」从生成环节前移到了校对环节,而且只前移了一次。

十几个地方引用同一个数字,你只需要在事实表里确认一次。


四、写的时候:三条硬规则

数字管好了,接下来是正文生成本身。我没有靠「更好的 prompt 措辞」,而是把三条规则写死进了 system prompt,让模型没有自由发挥的空间。

规则一:找不到依据,就写 [待补充]

这是整个项目里降幻觉最有效的一条。原文是这样的:

所有数字必须来自「本项目事实」或「本项目文件原文」;两者都找不到的数值一律标注 [待补充],禁止借用历史知识页/历史原文里的数值。

为什么这条比「请不要编造数字」有用?因为它把「幻觉」转化成了「待办事项」

模型的行为你付出的代价
编一个数字无法察觉,直到出事
标一个 [待补充]一眼看到,去查一次,填上

宁可留一个显眼的空,也不要填一个看不出问题的错。

而且 [待补充] 在编辑器里是可编辑的底色标记(不是装饰性样式)。于是你的工作流就变成了:

搜索「[待补充]」 → 逐个填 → 完事

而不是:

通读几十万字 → 试图从中找出哪句话不对 → 找不到

这就是第二节那个新标准的落地——把「通读找错」换成「搜一个词、改几处」。

规则二:冲突了听谁的,写死一条链

当不同来源的数字打架时,如果不给明确的优先级,模型会随机挑一个。所以裁决链是硬编码的:

本项目文件原文  >  本项目事实表  >  历史知识页 / 工法

这里有个反直觉的地方值得说一下:第一名的「文件原文」排在「事实表」前面。

因为事实表本身就是从文件里抽出来的。如果两者冲突,说明抽取环节可能有误——这时候当然要信原文,而不是信我那张二手表。

顺带提一句,上下文注入还有另一条优先级(谁的话更该听):

本次用户特别要求  >  用户经验偏好  >  项目文件原文  >  事实表  >  知识页/工法

规则三:历史资料——给风格,不给数据

历史施组是最好的写作范本,但同时也是最危险的幻觉来源,因为里面的数字全是别的项目的。

所以对历史资料我做了严格的分层使用:

✅ 可以借鉴❌ 严禁照搬
行文结构、组织顺序项目名、标段号
专业措辞、通用工艺描述里程桩号、日期工期
通用质量/安全/环保措施工程量与数量、造价
通用管理要求人员机械台数、工点名称

一句话:不依赖具体项目的文字可以借鉴,一切项目特定且可量化的内容严禁照搬。

这条规则有个很有意思的副产品。我原本以为,把历史章节的「编制方式/要点」结构喂给模型,会让它写得更好。

实测结果相反:一旦给了结构,模型会连着结构里的历史数字一起搬过来,写出来的东西和历史上那一章高度同构,完全没有本项目的针对性。

所以最终方案是「给风格,不给内容;给形状,不给数字」——只给那章历史原文的完整文本做行文示范,加一份表图清单(这里该配什么表、什么图),但历史表格里的数据一个都不给。

正文生成

正文流式落进所见即所得编辑器,顶部是「写思路 → 编写正文 → 完成」的推进流程。


五、写之后:让人能查证

前面的措施都是「生成时」的约束。但人不可能完全信任一个黑箱,所以「生成完」还得解决三件事。

① 这一章到底用了什么素材,点开就能看

正文落库的时候,同时存一份取材摘要

{
  "experiences":     [{"id": 12,  "title": "…"}],
  "knowledge_pages": [{"id": 317, "title": "隧道工程 > 超前地质预报"}],
  "file_snippets":   [{"file": "招标文件.pdf", "section": "第三章 评标办法"}],
  "methods":         [{"id": 8,   "name": "超前地质预报施工工法"}],
  "fact_count": 23
}

编辑器里可以点开「取材来源」,看到这一章每条素材的原文。

它的价值不在于「更透明」这种虚词,而在于 核对时不用通读全文,只查你怀疑的那几段引自哪里。而且一旦发现某段写得不对,你能直接定位到是什么素材导致的——是素材本身的问题,还是匹配逻辑选错了素材。

可解释性是信任的前提。你敢用生成结果,是因为你随时能查证。

② 能确定算出来的,一行都别让模型算

模型不擅长的事,不要勉强它。这个项目里凡是能确定计算/判断的,全部交给代码:

环节交给代码做
事实去重(category, fact_key) 归并,取高置信度
事实版本管理值未变跳过;值变则旧行 expired + version+1
标段过滤applicable_lots 精确匹配,不靠模型记得
父子章节去重同选时只留父节点
评分类章节过滤正则剔除「评标/须知/前言/致谢」类章节
表格行级裁剪多标段整表按本标段词表裁行
序号 → ID 映射给模型看序号,程序映射回真实 ID

举一个我认为最典型的例子:评分项。

招标文件的评分办法结构化提取之后,程序会做覆盖完整性检查与分值总和核对——因为评分项漏一条,可能导致直接废标。

这种事情绝对不能交给模型「自觉」。模型会非常自信地告诉你「我已核对完毕,合计 100 分」,而实际上它可能漏了一条 5 分的项。

程序算一遍,1 秒钟的事。

③ 对话式还有个更阴的坑:它谎称「做完了」

正文生成的幻觉是「编内容」,而对话链路的幻觉是 「谎称已完成」——后者更致命,因为它会让你以为事情做好了,从而不再检查

我见过模型说「第三章已生成完毕」,但实际上工具根本没被调用成功。

对话式编制

对话式编制,工具执行过程可展开核对。

三层防护:

  1. 定稿核验——模型声称「已生成/已写入」,必须有本轮真实的工具成功记录背书,否则否决重做
  2. 剥除伪造痕迹——模型自己编的「[已执行工具 ✓]」标记会被主动剥掉
  3. 防模仿——历史消息不注入工具标注,避免它照着格式伪造

另外还有个我觉得挺好用的小设计:工具遇到错误时返回真实数据,而不是返回报错。

比如模型传了个不存在的章节 ID,工具不会甩一个异常,而是返回「当前项目实际节点如下:…」。模型下一步就能自我修正。

把错误变成可自愈的信息,而不是让用户重说一遍。


六、回过头看,整件事其实只做了一次转化

绕了一圈,这套东西的本质可以压缩成一句话:

把「我需要通读几十万字找错误」,变成「我搜一个关键词,改几处」。

  • 写之前,数字抽进事实表 → 十几个引用点,你只需确认一次
  • 写的时候,没依据就标 [待补充] → 错误从「看不见」变成「显眼的空」
  • 写之后,取材来源可查 + 确定性的交给代码 → 出问题能定位,不会算错

这三个动作指向的是同一个目标:不是让 AI 不犯错,而是让它的错处变得便宜。


七、但这套东西解决不了什么(诚实版)

我不想只讲好的一面。下面这些是它做不到的:

  • 模型理解错误。它可能把「桥面铺装」理解成「桥面防水层」,整段写得完全跑题。这类错误只能靠人工审。
  • 专业判断分歧。某个方案是否合理,模型给的答案可能和资深工程师不一致——这是判断题,不是知识题。
  • 长文档的一致性衰减。整本几百页的一致性,靠逐章生成是保证不了的,需要最后的通读环节(这也是「质检智能体」还在 roadmap 上的原因)。
  • 原文本身有错。如果招标文件里就有矛盾,模型会忠实地把矛盾抄下来。

但可以确定的是:人工核对的工作量会明显下降。

不是从 100% 降到 0%,而是从「逐字读几十万字」降到「查关键数字 + 审专业判断」

在标书这个场景里,这个降幅已经足够有价值了。


八、开源信息

仓库github.com/zeronezer/b…

  • Apache-2.0 —— 可商用、可修改、可闭源分发,唯一要求是注明来源
  • 完全免费,无付费墙、无企业版、无功能阉割
  • 数据全部在你自己服务器上,除了你主动调用的 LLM 和文档解析服务,没有数据外流
  • 不锁定模型——走 OpenAI 兼容接口,改个 LLM_BASE_URL 就能换任何厂商

技术栈:FastAPI + Vue 3 + TipTap + LangGraph + MySQL + MinerU + 通义千问

部署最省事的方式:把仓库丢给你的 AI 编程助手。项目自带 AGENTS.md,Claude Code / Codex / Cursor / Trae 都会自动读取,你只要说一句:

「按 AGENTS.md 把这个项目跑起来」

它就会自己装依赖、配环境、建表、起服务。缺的 API Key 它会开口问你要,不会瞎编一个。

仓库里还有个 思路/ 目录,把上面这些决定展开成了 7 篇完整复盘,每篇都写了取舍、代价和踩过的坑——包括这篇没展开的「怎么让知识命中得更准」和「怎么让内容不重复」。


如果这个项目对你有用,欢迎点个 Star。

也欢迎来 Issue 聊聊——尤其是「该不该用 RAG」这件事,我现在越来越觉得答案取决于你的文档是不是强结构,而这个判断我肯定还没想到所有情况。