在工程造价数字化项目里,最让人头疼的往往不是模型能力不够,而是模型“一本正经地胡说八道”。你问它 C30 混凝土的单价,它可能给你一个看起来合理、实际上完全对不上的数字;你问它某项清单的综合单价,它甚至会把单位“立方米”换算成“平方米”之后还信心满满地给出结果。这种问题在工程量清单(BOQ)定价场景中尤其致命。
本文将围绕 BoqCalc 这个项目,完整拆解一套能对 500 行规模 BOQ 自动定价、同时尽量杜绝 AI 幻觉的 Pipeline 设计方案。文章会覆盖背景概念、总体架构、防幻觉策略、完整代码实现、评估方法和工程落地建议。无论你是做造价软件、AI 应用开发,还是想了解如何用工程手段约束大模型,这篇文章都值得读完。
1. 背景:BOQ 定价场景为什么最怕 AI 幻觉
1.1 什么是 BOQ
BOQ 全称是 Bill of Quantities,也就是工程量清单。它是建筑工程、市政工程、安装工程等项目中非常重要的一份文件,通常由招标方或造价咨询方编制,用来描述一个项目需要完成的所有工程内容。
一张典型的 BOQ 表格,一般包含以下几个字段:
- 项目编码
- 项目名称
- 项目特征描述
- 计量单位
- 工程量数量
- 综合单价
- 合价
举个简单例子:
| 项目编码 | 项目名称 | 项目特征 | 单位 | 工程量 |
|---|---|---|---|---|
| 010101001001 | 平整场地 | 土壤类别:三类土 | m² | 1200 |
| 010402001001 | 矩形柱 | C30 混凝土,截面尺寸 400×400 | m³ | 86.5 |
| 010501001001 | 屋面卷材防水 | SBS 改性沥青防水卷材,厚度 4mm | m² | 980 |
造价的本质,就是把这些条目逐项定价,再汇总成整个项目的造价。这个过程要求每一条都有一个明确、可追溯、有依据的单价,而不是一个“大概数”。
1.2 大模型直接报价的四个坑
很多人第一次尝试用 AI 做造价,都会直接扔给大模型一句“帮我给这份清单报个价”。这看起来是 AI 最擅长的任务,但实际上踩坑非常严重。
第一个坑是价格幻觉。大模型内部并没有存储一份权威、实时、分地区的价格数据库。它的训练数据里可能有价格,但那些价格可能是三年前某地的、可能会被“平均”成不存在的区间值。当你要求它给出具体单价时,它会在概率上生成一个看起来像价格的数字——这就是典型的价格幻觉(hallucinating rates)。
第二个坑是单位混淆。BOQ 里最常见的单位有 m、m²、m³、kg、t、个、套等。模型经常会把“m³ 混凝土”和“m² 防水卷材”的单位逻辑搞混,导致单价直接差一个数量级。
第三个坑是地区与时间差异。同样的 C30 混凝土,在 A 城市和 B 城市的价格可能差 10% 以上,在不同月份也可能波动。通用大模型无法感知“你当前项目所在地”和“报价基准期”,自然无法给出准确价格。
第四个坑是不可追溯。造价行业强调“有据可查”。如果你问模型为什么这个单价是 480 元而不是 520 元,它给不出依据。而没有依据的报价,在审计和结算阶段根本无法通过。
1.3 BoqCalc 的定位:让 AI 只做擅长的事
BoqCalc 的核心思路不是让 AI 直接报价,而是让 AI 只做它擅长的事:解析非结构化文本、理解清单描述、做名称归一化和特征提取。至于价格数值本身,从可靠的价格知识库中检索;金额计算用代码完成,而不是让模型算数。
这样设计的好处很明显:模型不需要“记住”价格,就不会在价格上产生幻觉;模型不负责算术,就不会出现加减乘除错误。整条链路变成了解析、检索、计算、校验的工程化流程。
2. BoqCalc Pipeline 总体设计
2.1 这里说的 Pipeline 是什么
在 AI 工程实践里,Pipeline 指的是一条由多个处理阶段串联而成的数据处理流水线。每个阶段只负责一类任务,前一阶段的输出作为后一阶段的输入。
这里要区分一下:它和我们常说的 Redis Pipeline、Jenkins Pipeline 不是一回事。Redis Pipeline 是批量发送命令的机制,Jenkins Pipeline 是 CI/CD 流程编排。BoqCalc 里的 Pipeline 指的是“从 BOQ 文件输入到定价结果输出”的完整数据处理流程。
采用 Pipeline 而不是“一次大模型调用”的原因很现实:造价任务环节多,每个环节都有可能出错,拆开以后可以针对每个环节分别做降错、校验和兜底。某个环节出现问题,也能快速定位。
2.2 五个阶段的职责划分
BoqCalc Pipeline 可以拆成五个阶段:
- 输入解析:读取 Excel、CSV 或 PDF 文件,把 BOQ 条目转成结构化数据。
- 名称归一化:把项目名称、项目特征等非结构化文本,标准化成可以检索的物料名称、规格、单位和数量。
- 价格检索:根据归一化后的结果,从价格知识库中检索匹配的基准单价。
- 金额计算:用“工程量 × 单价”计算合价,并做四舍五入。
- 校验与输出:检查金额一致性、标记异常条目、输出带来源与置信度的结果表。
用一张简图可以表示成:
BOQ 文件 → 解析 → 名称归一化 → 价格检索 → 金额计算 → 校验输出2.3 为什么分阶段能抑制幻觉
分阶段抑制幻觉的本质,是把“事实查询”和“文本理解”两种截然不同的任务分开。
大模型适合做语义理解,但不适合做事实记忆。价格属于“事实”,放进数据库比塞进模型参数更可靠。而工程量清单里的项目特征、规格描述是“文本”,模型擅长把它转成结构化字段。
举个例子,清单里写的是“SBS 改性沥青防水卷材,厚度 4mm,热熔法施工”。模型只需要负责识别出:物料是 SBS 防水卷材,规格是 4mm。至于单价是多少,由代码去价格表里查“SBS 防水卷材 + 4mm”这一行的数值。模型不需要输出任何价格数字,自然就没有“编价格”的机会。
3. 技术选型与环境准备
3.1 环境版本
本文的示例以 Python 环境为主,重点演示 Pipeline 的实现思路。版本需要根据你的项目实际情况调整,本文示例以常见环境为例,重点演示配置思路。
推荐环境:
- Python 3.10 或更高版本
- 虚拟环境:venv 或 conda
- 依赖管理:pip 或 poetry
- 本地数据库:SQLite 可用于演示,生产环境建议使用 PostgreSQL
- 大模型接口:OpenAI 兼容接口,可对接云端模型或本地部署模型
需要提醒的是,大模型接口的包名和调用方式迭代很快。本文代码以 OpenAI 兼容接口为例,实际使用时请根据你选择的模型服务商调整 endpoint 和参数。
3.2 项目结构
建议按下面这种结构组织代码,方便后续扩展:
boqcalc/ ├── boqcalc/ │ ├── __init__.py │ ├── models.py # 数据模型 │ ├── parser.py # BOQ 文件解析 │ ├── normalize.py # 名称归一化(LLM 调用) │ ├── price_db.py # 价格知识库查询 │ ├── validator.py # 校验逻辑 │ ├── pipeline.py # Pipeline 主流程 │ └── config.py # 配置项 ├── data/ │ ├── boq_demo.xlsx # 示例 BOQ │ └── price_library.db # 价格知识库 ├── output/ │ └── result.xlsx # 定价结果 ├── requirements.txt └── README.md3.3 数据准备:BOQ 样例与价格知识库
为了演示,我们需要准备两份数据。
第一份是 BOQ 样例,可以用 pandas 直接生成,也可以从真实项目脱敏后整理。关键字段至少包含:项目编码、项目名称、项目特征、计量单位、工程量。
第二份是价格知识库,设计一张价格表。这张表可以放在 SQLite、MySQL 或 PostgreSQL 中,核心字段包括物料编码、物料名称、规格、单位、基准单价、地区、生效日期和价格来源。
价格表建表语句示例:
CREATE TABLE price_library ( id INTEGER PRIMARY KEY, material_code TEXT NOT NULL, item_name TEXT NOT NULL, spec TEXT, unit TEXT, base_price REAL NOT NULL, region TEXT, effective_date TEXT, source TEXT );这里的核心思想是:价格数值必须来自这一张可审计、可更新的表,而不是来自大模型生成。
4. 核心模块设计:四条防幻觉策略
4.1 策略一:数值计算与文本生成分离
这是 BoqCalc 最重要的一条防幻觉策略。它的原则是:大模型只负责理解和提取文本,不负责输出任何价格数字。
具体来说,在 Prompt 里就明确约束模型:
- 只输出 JSON 结构,包含 item_code、item_name、spec、unit、quantity 等字段。
- 不输出 unit_price、amount 等价格字段。
- 不确定的字段设置为 null,不要猜测。
这样模型输出的内容里压根没有“价格”这个维度,自然不可能编造价格。
这条策略在很多 AI 工程实践中都值得借鉴。当你发现模型在某类数值上频繁出错时,最有效的办法不是换更大的模型,而是把数值产生逻辑从模型生成逻辑里剥离出来。
4.2 策略二:用价格知识库做检索增强
纯靠 Prompt 约束还不够,因为名称归一化后需要真正拿到价格。这里我们引入价格知识库作为“事实来源”。
检索增强生成(RAG)在传统方案里通常用于让模型根据文档回答问题。但在 BoqCalc 里,我们不追求让模型“引用”文档生成答案,而是直接让代码查询数据库,拿到结构化价格记录。
这样做有几个好处:
- 价格有来源,可以追踪到某个价格文件、某期信息价或某份合同。
- 价格可批量更新,市场变化时只需要更新数据库。
- 可以按地区、时间、材料类型做精确过滤。
价格检索不是简单地等值匹配。对于有明确项目编码的条目,直接用编码匹配;对于没有编码或编码不完整的,需要结合 item_name + spec + unit 做多条件匹配。如果匹配不到,宁可标记为 missing_price,也不能让系统自动生成一个随机数字。
4.3 策略三:后置规则校验
即使前面做了分离,计算环节仍可能出现浮点误差、单位不一致、金额异常等问题。因此 Pipeline 末尾必须加一层规则校验。
常见校验规则包括:
- 金额是否等于“工程量 × 单价”的结果。
- 单价是否为合理的正数,且不超过某个合理范围。
- 单位是否与价格库中的单位一致。
- 是否存在大额异常值,比如某条金额比其他同类型条目高出 100 倍。
校验不通过的条目要设置为特殊状态,比如“人工复核”,而不是直接当作正常结果输出。
4.4 策略四:来源引用与置信度标记
造价用户很关心“这个价格凭什么”。因此每条定价结果都要带上价格来源,比如来源是信息价、市场询价、历史合同还是数据库默认值。
同时,我们可以根据匹配方式给每条结果标注置信度:
- 高置信度:项目编码精确匹配,且单位一致。
- 中置信度:名称+规格匹配,编码为空。
- 低置信度:仅名称模糊匹配,或需要人工确认。
低置信度的条目在输出时单独列出,提醒造价人员复核。这样既保证了自动化效率,也把“可能出错”的地方暴露出来,而不是让错误悄悄混入最终报价。
5. 完整代码实现
5.1 定义数据模型
先用 Pydantic 定义 BOQ 条目的数据结构。使用数据模型的好处是,每个阶段的数据流转都有类型约束,不容易出现字段错乱。
# 文件路径:boqcalc/models.py from typing import Optional from pydantic import BaseModel class BOQItem(BaseModel): line_no: int # 清单行号 item_code: Optional[str] = None # 项目编码 item_name: str # 项目名称 spec: Optional[str] = None # 项目特征/规格 unit: Optional[str] = None # 计量单位 quantity: float = 0.0 # 工程量 unit_price: Optional[float] = None # 综合单价 amount: Optional[float] = None # 合价 source: Optional[str] = None # 价格来源 confidence: str = "unknown" # 置信度 status: str = "pending" # pending/priced/missing_price/needs_review5.2 BOQ 文件解析模块
下面实现对 Excel 文件的解析。实际项目中,BOQ 可能来自 Excel、CSV、PDF 甚至 OCR 识别结果,但解析逻辑都是类似的:把每一行表格记录转成字典。
# 文件路径:boqcalc/parser.py import pandas as pd def parse_boq_file(file_path: str) -> list[dict]: """ 读取 BOQ Excel 文件,返回条目字典列表。 注意:表头列名需要根据实际文件调整。 """ df = pd.read_excel(file_path) rows = [] for idx, row in df.iterrows(): rows.append({ "line_no": idx + 1, "item_code": str(row.get("项目编码", "")).strip(), "item_name": str(row.get("项目名称", "")).strip(), "spec": str(row.get("项目特征", "")).strip(), "unit": str(row.get("计量单位", "")).strip(), "quantity": float(row.get("工程量", 0) or 0), }) return rows这里大家要注意,不同项目的 BOQ 模板表头可能差异很大。有的叫“项目特征”,有的叫“项目描述”,有的把规格写在单独一列。建议在实际工程中配置一个字段映射表,而不是写死列名。
5.3 价格知识库查询模块
价格查询模块是整个防幻觉体系的核心,所有价格数值都必须经过这里。
# 文件路径:boqcalc/price_db.py import sqlite3 from contextlib import closing class PriceLibrary: def __init__(self, db_path: str): self.db_path = db_path def lookup(self, material_code: str, item_name: str, spec: str, unit: str, region: str, quote_date: str): """ 根据物料信息、地区、报价日期查询价格。 优先使用编码匹配,其次使用名称+规格匹配。 """ with closing(sqlite3.connect(self.db_path)) as conn: conn.row_factory = sqlite3.Row # 1. 精确编码匹配 if material_code: row = conn.execute( """ SELECT * FROM price_library WHERE material_code = ? AND region = ? AND effective_date <= ? ORDER BY effective_date DESC LIMIT 1 """, (material_code, region, quote_date), ).fetchone() if row: return self._to_price_info(row, "high") # 2. 名称 + 规格匹配 if item_name: row = conn.execute( """ SELECT * FROM price_library WHERE item_name = ? AND (? = '' OR spec = ?) AND unit = ? AND region = ? AND effective_date <= ? ORDER BY effective_date DESC LIMIT 1 """, (item_name, spec or "", spec or "", unit, region, quote_date), ).fetchone() if row: confidence = "high" if spec else "medium" return self._to_price_info(row, confidence) return None @staticmethod def _to_price_info(row: sqlite3.Row, confidence: str) -> dict: return { "unit_price": row["base_price"], "source": row["source"], "effective_date": row["effective_date"], "confidence": confidence, }这里的查询策略值得展开说一下。先尝试用项目编码精确匹配,因为编码是唯一的;编码匹配不到时,再降级到“名称+规格+单位”匹配。匹配置信度也会随之变化,方便后续人工复核。
5.4 名称归一化模块
对于编码缺失、名称不规范、规格复杂的清单条目,我们需要借助大模型做名称归一化。这个模块只负责从非结构化文本中提取结构化信息,不负责定价。
# 文件路径:boqcalc/normalize.py import json import os from openai import OpenAI # 通过环境变量配置 Key 和 Base URL,不要把密钥写进仓库 client = OpenAI( api_key=os.getenv("OPENAI_API_KEY"), base_url=os.getenv("OPENAI_BASE_URL"), ) SYSTEM_PROMPT = """你是工程量清单解析助手。 请从用户给出的 BOQ 条目中提取结构化信息,只输出 JSON。 字段包括:item_code, item_name, spec, unit, quantity。 规则: 1. 不确定的字段设为 null,不要猜测。 2. 严禁输出价格、单价、金额相关字段。 3. 数量只保留数字,不要带单位。 4. 单位统一转换为 m、m2、m3、kg、t、个、套 等标准单位。 """ def normalize_with_llm(raw_item: dict) -> dict: resp = client.chat.completions.create( model=os.getenv("LLM_MODEL", "gpt-4o-mini"), messages=[ {"role": "system", "content": SYSTEM_PROMPT}, {"role": "user", "content": json.dumps(raw_item, ensure_ascii=False)}, ], temperature=0, ) content = resp.choices[0].message.content.strip() # 实际项目中可能要处理模型返回的 markdown 代码块包裹,需要做清理 if content.startswith("```"): content = content.strip("`") content = content.removeprefix("json").strip() return json.loads(content)如果要在生产环境使用,建议开启模型的 JSON 输出模式,或者使用 response_format 约束,而不是靠解析纯文本 JSON。模型版本不同,支持的参数名也不同,请以对应模型文档为准。
5.5 Pipeline 主流程
把上面的模块串起来,就得到了 BoqCalc Pipeline 的主流程。
# 文件路径:boqcalc/pipeline.py from boqcalc.models import BOQItem from boqcalc.parser import parse_boq_file from boqcalc.normalize import normalize_with_llm from boqcalc.price_db import PriceLibrary class BoqCalcPipeline: def __init__(self, price_db: PriceLibrary, region: str, quote_date: str): self.price_db = price_db self.region = region self.quote_date = quote_date def run(self, file_path: str) -> list[BOQItem]: raw_items = parse_boq_file(file_path) results = [] for raw in raw_items: item = BOQItem(**raw) # 第 1 步:如果编码为空,或编码查不到价格,则用 LLM 归一化 normalized = self._normalize(item) # 第 2 步:查价格库 price_info = self.price_db.lookup( material_code=normalized.get("item_code") or "", item_name=normalized.get("item_name") or "", spec=normalized.get("spec") or "", unit=normalized.get("unit") or "", region=self.region, quote_date=self.quote_date, ) # 第 3 步:处理检索结果 if price_info is None: item.status = "missing_price" item.confidence = "low" else: item.unit_price = price_info["unit_price"] item.amount = round(item.quantity * price_info["unit_price"], 2) item.source = price_info["source"] item.confidence = price_info["confidence"] item.status = "priced" results.append(item) return results def _normalize(self, item: BOQItem) -> dict: # 如果已经有编码,且库中能查到,就不需要调用 LLM if item.item_code: price_info = self.price_db.lookup( material_code=item.item_code, item_name=item.item_name, spec=item.spec or "", unit=item.unit or "", region=self.region, quote_date=self.quote_date, ) if price_info: return { "item_code": item.item_code, "item_name": item.item_name, "spec": item.spec, "unit": item.unit, "quantity": item.quantity, } # 编码缺失或编码查不到时,用 LLM 提取结构化信息 try: result = normalize_with_llm(item.model_dump()) return result except Exception: # LLM 调用失败时,保留原始字段,让价格库做模糊匹配 return { "item_code": item.item_code, "item_name": item.item_name, "spec": item.spec, "unit": item.unit, "quantity": item.quantity, }这里有一个工程细节:优先用规则和编码匹配,只有在规则覆盖不到时才调用大模型。这样既能减少外部接口调用成本,也能降低延迟。500 行 BOQ 如果全部走 LLM,可能需要几分钟;但如果大部分条目能靠编码命中,速度会快很多。
5.6 运行与输出示例
下面看一下运行主流程的方式:
# 文件路径:run_pipeline.py from boqcalc.pipeline import BoqCalcPipeline from boqcalc.price_db import PriceLibrary if __name__ == "__main__": price_db = PriceLibrary("data/price_library.db") pipeline = BoqCalcPipeline( price_db=price_db, region="杭州", quote_date="2025-06-01", ) results = pipeline.run("data/boq_demo.xlsx") for item in results: print(item)预期输出的核心字段如下:
| 行号 | 项目名称 | 单位 | 工程量 | 单价 | 合价 | 状态 | 置信度 |
|---|---|---|---|---|---|---|---|
| 1 | 平整场地 | m² | 1200 | 5.20 | 6240.00 | priced | high |
| 2 | 矩形柱 C30 | m³ | 86.5 | 486.00 | 42039.00 | priced | high |
| 3 | 屋面卷材防水 | m² | 980 | 42.50 | 41650.00 | priced | medium |
| 4 | 某某特殊材料 | t | 2 | None | None | missing_price | low |
所有 priced 的条目都有 source 字段,标明价格来自哪个信息价文件或哪期价格库。missing_price 条目被单独暴露出来,不会自动生成一个“编造”的价格。
6. 如何用数据验证“不幻觉”
6.1 三个评估维度
要证明一个 AI Pipeline“不幻觉”,不能靠感觉,要靠数据。建议从三个维度评估。
第一个维度是准确率。也就是定价结果与人工审核结果一致的比例。建议拿历史项目中已经审定过的 BOQ 做回测,跑完 Pipeline 后与审定单价对比。
第二个维度是幻觉率。我们把“没有价格依据,但系统仍输出了价格”的条目定义为幻觉条目。BoqCalc 的设计目标,是让幻觉率归零。也就是说,查不到价格时就标记 missing_price,绝不硬编一个数字。
第三个维度是可追溯率。也就是“有明确价格来源”的条目占总定价条目的比例。对造价审计来说,可追溯率比准确率更重要。即使某个价格和实际有偏差,只要来源清晰,就能人工检查并修正。
6.2 500 行 BOQ 的测试方法
以 500 行 BOQ 的测试集为例,可以这样设计测试流程:
- 准备一份包含约 500 条清单的测试文件,其中约有 50 条故意设置为复杂描述或缺少编码。
- 运行 BoqCalc Pipeline。
- 统计 priced、missing_price、needs_review 三个状态的条目数量。
- 抽取 priced 中置信度低的条目和全部 missing_price 条目,交给造价人员复核。
- 将测试文件跑 3 次,检查同一输入是否得到稳定输出,重点看 LLM 解析部分是否出现随机抖动。
这里还要关注一个性能问题。500 行 BOQ 串行调用 LLM 可能较慢,建议在 Pipeline 中加入缓存和批量处理机制。对相同或相似的清单描述,直接命中缓存,减少重复调用。
6.3 预期结果与人工复核
一个合格的运行结果应该是这样的:
- 500 条中,约 450 条直接命中价格库,标记为 high 置信度。
- 20 条需要 LLM 归一化后才能匹配,标记为 medium 置信度。
- 20 条被标记为 missing_price,等待人工补充价格依据。
- 10 条被标记为 needs_review,可能存在单位问题或规格歧义。
整个过程中,不应该出现一条“没有来源但系统自动给出一个数字”的记录。如果有,那说明 Pipeline 的某个环节违反了设计原则,需要立即修复。
7. 常见问题与排查思路
7.1 高频问题对照表
| 问题现象 | 常见原因 | 解决思路 |
|---|---|---|
| 部分条目标记为 missing_price,但实际存在价格 | 价格库缺少别名或规格不统一 | 扩充价格库别名映射,增加规格字段清洗 |
| LLM 解析返回的不是合法 JSON | 模型输出格式不稳定 | 启用 JSON 输出约束,增加异常后的重试逻辑 |
| 单价匹配错误,比如把“C30 矩形柱”匹配成“C25 矩形柱” | 仅按名称匹配,忽略规格 | 匹配时加入规格维度,模糊匹配后增加候选排序 |
| 金额与“数量×单价”不一致 | 浮点精度问题,或四舍五入时机不一致 | 统一用 Decimal 或统一 round 规则 |
| Pipeline 创建时报依赖错误 | 依赖版本冲突,例如 pydantic 版本与 openai 库不兼容 | 用 requirements 或 poetry 锁定版本,在虚拟环境中重建 |
| 500 行跑得很慢 | 每条都调用了大模型 | 规则优先命中、增加缓存、使用批量请求 |
| 同一输入两次运行结果不一致 | 大模型温度未设置或随机性导致 | 设置 temperature=0,必要时固定随机种子 |
7.2 一次典型幻觉问题的排查过程
假设你在测试时发现,有一条“C30 矩形柱”的单价变成了 980 元,而价格库里 C30 矩形柱的价格明明是 486 元。
排查过程可以按下面几步走。
第一步,看 pipeline 日志,确认这条记录是否走了 LLM 归一化。如果走了,说明原始 BOQ 里可能没有项目编码,或者编码没有命中。
第二步,查看 LLM 归一化后的 JSON,确认 item_name 和 spec 是否被正确解析。这里可能出现了“矩形柱混凝土”和“矩形柱模板”的混淆,spec 字段没有提取出来。
第三步,查看价格库查询条件,确认查询传入了错误的 spec。如果 spec 为空,查询就退化成“仅按名称匹配”,此时很容易命中同名称但不同规格的第一条记录。
第四步,修复方案是在 _normalize 阶段加强规格提取,并让价格库查询在命中多条记录时按“规格相似度”排序,而不是直接取第一条。
这种排查思路的核心是:把问题定位到具体阶段,而不是笼统地归因于“AI 不靠谱”。只要每个阶段都有日志和中间结果,排错效率会非常高。
8. 工程落地最佳实践
8.1 价格知识库治理
BoqCalc 的准确性下限由价格知识库决定。知识库质量越高,Pipeline 的表现越好。建议做好以下几点:
- 每条价格记录都必须有 source 字段,注明价格来源。
- 价格要按地区、有效期管理,过期价格自动失效。
- 物料名称和规格要建立统一的规范化标准,避免“C30混凝土”和“C30 砼”同时存在。
- 定期更新价格库,并保留历史版本,方便追溯报价时用了哪一期价格。
8.2 模型与部署建议
如果项目对数据安全要求较高,可以采用本地部署的 AI 模型,例如通过 vLLM 或 Ollama 部署开源模型。BOQ 数据属于项目敏感数据,要谨慎发送到外部 API。
本地部署时,名称归一化任务难度不高,选用 7B 到 14B 级别的开源模型通常足够。真正的大头是价格知识库和规则引擎,而不是模型参数规模。
8.3 安全与审计
工程落地必须考虑安全和审计。至少要做到:
- API Key 放在环境变量或密钥管理系统中,不要提交到 Git 仓库。
- Pipeline 的每次运行记录日志,包括输入文件、模型版本、知识库版本、输出结果。
- 涉及生产数据的操作,要经过授权,并在测试环境验证后再执行。
- 对自动生成的结果保留人工复核机制,尤其是金额异常或低置信度的条目。
这些要求不是可有可无的,而是造价行业审计流程的基本要求。没有审计痕迹的自动报价,很难真正投入使用。
8.4 性能优化
500 行 BOQ 对计算来说压力不大,真正的性能瓶颈在 LLM 调用。优化思路有三个方向。
第一是减少调用。能通过规则、编码、别名表匹配的条目,不要调用大模型。
第二是批量处理。把多条待归一化的清单条目打包成一次请求,让模型输出一个 JSON 数组,减少网络往返。不过要注意控制单次请求的 token 量,避免超过模型上下文限制。
第三是缓存。相同或相似的清单描述,直接把第一次归一化结果缓存下来。在大型项目里,同一批 BOQ 中很多条目描述是重复的,缓存收益非常明显。
9. 总结与下一步学习路线
BoqCalc 这个项目最有价值的点,不在于它用了多强大的模型,而在于它用工程手段重新分配了任务边界:大模型负责理解文本,数据库负责记住事实,代码负责计算和校验。这才是 AI 工程实践中真正值得反复体会的设计思路。
如果要把这个方案继续深入下去,可以从三个方向延伸。
第一个方向是 Agent 化。把价格库查询、异常复核、人工反馈闭环做成一个 Agent 工作流,让系统在遇到 missing_price 时自动发送给造价人员,收到反馈后自动更新知识库。
第二个方向是评估集建设。积累一批带标准答案的 BOQ 测试集,每次模型升级或价格库更新后都跑一遍回归测试,用数字衡量系统是否“退化”。
第三个方向是模型中台化。把名称归一化能力封装成内部服务,供投标、估算、结算等多个业务线复用。
最后建议你动手跑一遍本文代码,拿一份自己的 BOQ 模板和价格表试一试。重点看两条逻辑:一条是“查不到价格时不硬编数字”,另一条是“每条价格都有来源”。这两条做到了,这个 Pipeline 就真正拥有了在业务中落地的底气。