详情

首页手游攻略 用AI简化供应商报价对比:从数据归一化到采购建议

用AI简化供应商报价对比:从数据归一化到采购建议

佚名 2026-09-03 20:10:57

在人工智能内容学习中,用 AI 简化供应商报价对比:从数据归一化到采购建议是常见主题。很多人在阅读时会遇到概念分散、步骤不清和注意点难以归纳的问题。本文按照基础概念、操作流程和关键细节,对相关内容进行整理。

一份询价单发出去,带着清晰的物料清单,十二家供应商回信。没有两家答得一样。一家发来工作簿,价格列叫 "Unit Price"、数量列叫 "Qty";另一家发来 PDF,同样的两列变成了 "Rate" 和 "Pcs";第三家用欧元报价,数量列用的是 Lot 而不是 Qty,还加了个脚注说"价格含运费、不含增值税"。在你能对比任何一行明细之前,这些都得先变成同一个意思。

vendor_a.xlsx   Part No   | Description   | Qty | Unit Price | Total
vendor_b.pdf    Item      | Specification | Pcs | Rate       | Amount
vendor_c.xlsx   SKU       | (空)         | Lot | Unit Cost  | Net (EUR)

同样的信息,三种叫法、三种单位、一次币种换算。这部分内容会走完一个完整的 .NET 实现,把这样一个收件箱变成一份排序好的采购建议,用的是 Spire.Agent.Office——更重要的是,贯穿整个设计只有一条分界线:

智能体理解供应商字段的含义;你的应用决定每个零件由哪家供应商胜出
智能体(语义层)应用(确定性层)
Rate / Unit Cost / Unit Price 映射为 unitPrice把币种换算成美元
从 PDF 和 Excel 中提取明细校验数量与总价
Qty / Pcs 映射为 quantityLot 仅在明确作为报价数量时)打分,并为每个零件选出胜出供应商

下面的一切都围绕这条边界展开:智能体负责把异构的 Office/PDF 文档落到统一的规范结构(canonical schema)上;应用负责每一个参与决策的数字。

1. 定义规范报价结构

任何对比的第一步,都是先定一个所有报价都能归约过去的目标结构。把它定义成一个普通的 C# 模型——这就是规范结构:

public sealed class QuoteLine
{
    public string Vendor { get; set; } = "";
    public string? Part { get; set; }
    public string? Description { get; set; }
    // 可空:智能体在字段缺失或含义不清时留 null。
    public decimal? Quantity { get; set; }
    public decimal? UnitPrice { get; set; }
    public string? Currency { get; set; }
    public decimal? Total { get; set; }
    public int? DeliveryWeeks { get; set; }
    public int? PaymentTerms { get; set; }

    // 由应用在校验阶段写入 —— 智能体从不碰它。
    public string? Flag { get; set; }
}

从供应商自己的表头到这个结构的映射,正是问题的核心:

源字段(每家不同)规范字段示例
Part No / Item / SKUPartA-1024
Qty / PcsQuantity500
Lot(仅当文档明确将其作为报价数量)Quantity5
Unit Price / Rate / Unit CostUnitPrice12.50
Net / Amount / TotalTotal6,250.00
文档中标明的币种符号 / 代码CurrencyEUR
Delivery / Lead TimeDeliveryWeeks4
TermsPaymentTerms30

手写的解析器每来一家供应商都得重新推一遍这张表;智能体的意义,就在于按含义而不是按字符串匹配去套用这张表。

2. 加载异构的报价

收件箱是一个同时装着两种文档的文件夹,没有任何预处理步骤:智能体以原生格式读取每个文件,所以 PDF 和 Excel 用同一条指令、通过两个对象类型来处理。

C:sourcinginbox
   ├─ vendor_a.xlsx
   ├─ vendor_b.pdf
   └─ vendor_c.xlsx

3. 用智能体把每份报价归一化

Spire.Agent.Office 对 Word、Excel、PowerPoint、PDF 统一暴露一个 AI() 处理器。装上包、一次性配置好智能体,再遍历收件箱:

dotnet add package Spire.Agent.Office
using Spire.Agent.Office.AI;
using Spire.Agent.Office.Extensions;
using Spire.Pdf;
using Spire.Xls;

AIOptions agent = new AIOptions
{
    SpireToken = spireToken,        // 从你的 Spire.Agent.Office 账户获取
    WorkDir    = @"C:sourcingwork",
    TimeoutMs  = 300_000
};

const string instruction =
    "读取这份供应商报价,提取明细。在输入文档所在的同一个目录下新建一个 .json 文件," +
    "向其中写入一个 JSON 数组,每个明细行一个对象,每个对象只能包含这些字段:vendor、" +
    "part、description、quantity、unitPrice、currency、total、deliveryWeeks、paymentTerms。" +
    "vendor 是签发这份报价的公司,取自文档表头,绝不能留 null。deliveryWeeks 和 " +
    "paymentTerms 是整数:deliveryWeeks 是交货周期(周),paymentTerms 是" +
    "付款期限(天),所以 'Net 30' 要变成 30。把供应商" +
    "自己的表头映射到这个结构上——'Rate'、'Unit Cost'、'Unit Price' 都表示 unitPrice;'Qty'、" +
    "'Pcs' 都表示 quantity;'Lot' 仅在文档明确将其作为报价数量时才映射。若某个值缺失或" +
    "含义不清,留 null,不要猜测。输出文件是交付物——必须确实创建并写入磁盘。";

foreach (string file in Directory.GetFiles(inboxPath))
{
    string jsonPath = Path.Combine(normPath, Path.GetFileNameWithoutExtension(file) + ".json");

    // 智能体调用走 LLM,偶尔会失败或不写输出文件;重试几次。
    AIResult r = null!;
    Exception? last = null;
    for (int attempt = 1; attempt <= 3; attempt++)
    {
        try
        {
            if (file.EndsWith(".pdf", StringComparison.OrdinalIgnoreCase))
            {
                using var quote = new PdfDocument();
                quote.LoadFromFile(file);
                r = quote.AI(agent).ExecuteInstruction(quote, instruction, jsonPath, Array.Empty<string>());
            }
            else
            {
                using var quote = new Workbook();
                quote.LoadFromFile(file);
                r = quote.AI(agent).ExecuteInstruction(quote, instruction, jsonPath, Array.Empty<string>());
            }
        }
        catch (Exception e) { last = e; }

        if (r is not null && r.Success) break;
    }

    if (r is null || !r.Success)
        throw new InvalidOperationException($"归一化失败 {Path.GetFileName(file)}:{r?.ErrorMessage ?? last?.Message}");
}

每次调用返回一个 AIResult,每份报价变成一份已经符合规范结构的 JSON 文件——vendor_b.pdf 产出的字段和 vendor_a.xlsx 完全一致。到这里,智能体做的只有语义提取和映射,没有任何定量计算。

同一套附件机制也能走得更远:把历史价格表或零件规格书与报价一起作为附件传入,智能体能标出与该零件通常价格明显偏离的明细——这是任何阈值都无法编码的判断。让它保持"建议"性质:问题由智能体提出,决定仍由你的代码来下。

4. 校验数据并换算币种

确定性的一半从这开始。把每份归一化后的 JSON 读回模型,用固定的汇率表换算成单一币种,再把站不住的行剔除:

using System.Text.Json;

private static readonly Dictionary<string, decimal> Fx = new()
{
    ["USD"] = 1.00m,
    ["EUR"] = 1.09m,
    ["GBP"] = 1.27m,
};

var quotes = new List<QuoteLine>();
foreach (string json in Directory.GetFiles(normPath, "*.json"))
{
    quotes.AddRange(JsonSerializer.Deserialize<List<QuoteLine>>(File.ReadAllText(json),
        new JsonSerializerOptions { PropertyNameCaseInsensitive = true })!);
}

foreach (var q in quotes)
{
    if (string.IsNullOrWhiteSpace(q.Part)) q.Flag = "缺少零件号";
    else if (q.Quantity is null or <= 0)   q.Flag = "数量非正";
    else if (q.UnitPrice is null or <= 0)  q.Flag = "单价非正";
    else if (!Fx.TryGetValue(q.Currency ?? "", out var fx))
                                           q.Flag = $"不支持的币种:{q.Currency ?? "(无)"}";
    else if (q.Total is null)              q.Flag = "缺少总价";
    else if (q.DeliveryWeeks is null)      q.Flag = "缺少交期";
    else if (q.PaymentTerms is null)       q.Flag = "缺少付款条款";
    else if (Math.Abs((q.Quantity.Value * q.UnitPrice.Value
                     - q.Total.Value) * fx) > 0.01m)
                                           q.Flag = "数量 × 单价 ≠ 总价";
}

汇率表、校验阈值和算术,全都放在代码里——这样它们才能被评审、被版本化管理、被审计时引述。没有汇率的币种会被标记,而不是被静默当成美元;评分所依赖、却被智能体留空的任何字段也一样会被标记。Description 是唯一可以留空的字段——它不参与任何决策。智能体从没参与这些判断,它只提供了字段。

5. 用确定性规则打分排序

一条干净的行就可以打分。权重是固定的函数,不是模型的意见:

private const decimal PriceTargetUsd = 15.00m;   // 每件预算

private static double Score(decimal unitUsd, int deliveryWeeks, int paymentTerms)
{
    double price    = unitUsd <= PriceTargetUsd ? 50.0
                    : 50.0 * (double)(PriceTargetUsd / unitUsd);
    double delivery = deliveryWeeks <= 4 ? 30.0 : 0.0;
    double terms    = paymentTerms >= 30 ? 20.0 : 0.0;
    return price + delivery + terms;
}

把它套到每一条有效行上、排序,再写出推荐步骤要读的两份数据——排序后的报价行,和被剔除的异常行。这里的每一行都已通过第 4 步的校验,字段必然非空,!.Value 只是让编译器满意:

public sealed record RankedQuote(
    string Vendor, string Part, decimal UnitUsd,
    int DeliveryWeeks, int PaymentTerms, double Score);

var scored = new List<RankedQuote>();
foreach (var q in quotes.Where(q => q.Flag is null))
{
    decimal unitUsd = q.UnitPrice!.Value * Fx[q.Currency!];
    int delivery    = q.DeliveryWeeks!.Value;
    int terms       = q.PaymentTerms!.Value;
    scored.Add(new RankedQuote(q.Vendor, q.Part!, unitUsd, delivery, terms,
        Score(unitUsd, delivery, terms)));
}
scored.Sort((a, b) => b.Score.CompareTo(a.Score));

var flagged = quotes.Where(q => q.Flag is not null).ToList();

File.WriteAllText(@"C:sourcingdataranked-quotes.json",
    JsonSerializer.Serialize(scored, new JsonSerializerOptions { WriteIndented = true }));
File.WriteAllText(@"C:sourcingdataflagged-lines.json",
    JsonSerializer.Serialize(flagged, new JsonSerializerOptions { WriteIndented = true }));

6. 生成采购建议

最后这份文档又交回给智能体——但它是基于排序后的数据撰写,而不是选择。排序在第 5 步就已经定下来了,赢家也一样——每个零件一位:

using Spire.Doc;

// 每个零件一位赢家,由代码决定,不由模型决定。
string winners = string.Join("、", scored
    .GroupBy(v => v.Part)
    .Select(g => g.OrderByDescending(v => v.Score).First())
    .Select(w => $"{w.Vendor}({w.Part})"));

// 智能体从指令里读取数据,而不是作为文件附件。
string rankedJson  = File.ReadAllText(@"C:sourcingdataranked-quotes.json");
string flaggedJson = File.ReadAllText(@"C:sourcingdataflagged-lines.json");

using (Document report = new Document())
{
    string instruction = $"""
        写一份采购建议。
        排序后的明细(已评分并按得分降序,每行一个对象):{rankedJson}

        被剔除、未参与评分的异常行(每条都带 "flag" 字段):{flaggedJson}

        各零件的获胜供应商是:{winners}
        严格按上面给出的结果写明每个零件的赢家。

        点名排序前三的明细,附上排序明细的对比表(供应商、零件、美元单价、交期、条款、
        得分),列出异常行,用一句采购员能直接行动的
        白话解释每条异常的原因。
        不要重新计算得分,也不要另选赢家。
        保持排版干净、可打印。
        """;

    AIResult result = report.AI(agent).ExecuteInstruction(
        report,
        instruction,
        @"C:sourcingoutputpurchasing-recommendation.docx",
        Array.Empty<string>());

    if (result is null || !result.Success)
        throw new InvalidOperationException($"报告生成失败:{result?.ErrorMessage}");
}

赢家是写进指令里的纯数据,而不是模型从排序里得出的结论:物料清单里的每个零件,分别授予得分最高的供应商。它读取排序数据是为了表格和叙述;决定在上面已经做出,指令也明确禁止它重新推导这些结论。把 savePath 换成 .pdf,同一条指令就能导出建议供分发。异常标记在第 4 步也由代码判定——智能体从没评判任何一行。它在这一侧贡献的是语言:把 Flag数量 × 单价 ≠ 总价 这类生硬的标记,改写为采购经理真正会读的句子,而不再重新裁决这个判断。代码判定,模型叙述——同一条边界,这次体现在输出侧。

7. 检视智能体会话

每次 ExecuteInstruction 调用都以会话(session)运行,Spire.Agent.Office 会把输入、生成的输出和执行产物一起放在 WorkDir 下:

C:sourcingwork
└─ .office_use_tmp
   ├─ Excel
   │  └─ 0814140210_a1b2c3d4        ← vendor_a.xlsx 归一化
   │     ├─ vendor_a.xlsx
   │     └─ normalized.json
   ├─ Pdf
   │  └─ 0814140223_e5f6a7b8        ← vendor_b.pdf 归一化
   │     ├─ vendor_b.pdf
   │     └─ normalized.json
   └─ Word
      └─ 0814140411_c9d0e1f2        ← 推荐文档生成
         ├─ ranked-quotes.json
         └─ purchasing-recommendation.docx

会话目录是一份完整记录——处理了什么、产出了什么,都能回溯,是整个工作流的审计轨迹。重点不在于智能体内部是怎么完成提取的:在应用层,异构的供应商文档被归约到同一套规范结构上,而校验、汇率换算、打分和供应商选择全都留在你的代码里,显式、确定。

这正是这套输出能在真实采购工作流里被信任的原因。供应商对某个数字有异议时,你可以指向会话目录,精确展示他们的报价是怎么被读出来的。

8. 完整工作流

整条流水线,从头到尾:

static void Run()
{
    AIOptions agent = new() { SpireToken = spireToken, WorkDir = workDir, TimeoutMs = 300_000 };

    NormalizeQuotes(agent, inboxPath, normPath);       // 第 3 步 —— 智能体,语义
    var quotes = LoadAndValidate(normPath);             // 第 4 步 —— 代码,确定性
    var (scored, flagged) = ScoreAndRank(quotes);       // 第 5 步 —— 代码,确定性
    WriteDatasets(scored, flagged);                     //         序列化为 JSON
    GenerateRecommendation(agent, outputPath);          // 第 6 步 —— 智能体,写文档
}

这套设计里可复用的部分,并不是报价逻辑本身——而是它所划出的那条边界。语义提取(从异构 Office/PDF 文档里读出含义)属于智能体;每一个定量决策(币种、校验、打分、排序)属于你的代码。同样的切分可以直接搬到发票处理、简历筛选和 RFP 响应上:换掉规范结构、指令和打分函数,保留这条边界。

要自己跑通这套流程,Spire.Agent.Office 集成入门教程讲了集成配置,产品概览列出了 SDK 暴露的四个智能体(Word、Excel、PowerPoint、PDF)。

相关资讯
点击查看更多
游戏推荐
推荐专题
热门阅读
推荐下载