AI 工具

用 FastGPT 自动评测知识库问答:固定问题、引用来源与拒答验证

改了知识库或提示词以后,原来答对的问题可能就答错了。 这篇做 evalapp.py:用一组固定问题调用 FastGPT 应用,检查回答里有没有关键信息、引用是不是来自预期的文件、知识库里没有答案的问题是否老实说“不知道”,结果写进 CSV。

BINBHO · 图文教程

改了知识库或提示词以后,原来答对的问题可能就答错了。 这篇做 eval_app.py:用一组固定问题调用 FastGPT 应用,检查回答里有没有关键信息、引用是不是来自预期的文件、知识库里没有答案的问题是否老实说“不知道”,结果写进 CSV。

先看清成品

  • 用本地 mock(我写的)真实运行,第一轮 3/5 通过,暴露了两个知识库内容问题:IT 指南里“年假请查看员工手册”这种只做转介的段落抢走了检索;“丢了”和“丢失”字面不同没匹配上。
  • 改文档后 4/5,再放宽 mock 的检索阈值后 5/5。
  • 用错误 Key 跑时每题都记为“调用失败:514”,不会误判成答错。3 项 pytest 通过。

合规提醒:只用 FastGPT 官方 OpenAPI 和你自己账号的 API Key。官方文档说明 API Key“相当于当前账号在当前团队下的访问凭证”,所以只能放在服务端,不要写进网页或 App。开源版是 Apache 2.0 加附加条件:可以商用,但做类似 FastGPT 的多租户 SaaS、或去掉控制台的 LOGO/版权信息,需要另外取得商业授权(以仓库 LICENSE 为准)。上传到知识库的文档要有权使用,不放个人隐私。

运行说明:box 不能用 Docker,也没有 FastGPT 账号,所以没有部署或调用真实的 FastGPT 应用。真实跑过的是:用无效 Key 调用 FastGPT 云服务(cloud.fastgpt.cn 和 cloud.fastgpt.io,2026-10-21 21:26–21:28,UTC+8),返回原样贴在文中;以及全部本地代码。正常的回答和引用来自我写的 mock,结构照官方文档 detail=true, stream=false 的响应示例;官方也说明不同版本 responseData 的字段可能不同。 mock 的检索只是“字符重合度”,比 FastGPT 的向量检索弱得多;它的作用是让评测脚本能在本地跑起来。

做完之后你会得到什么?

成果 说明
eval_app.py 批量提问 + 判定 + 写 CSV
questions.csv 5 道评测题(含 1 道知识库里没有答案的)
mock_fastgpt.py 本地假 FastGPT(我写的)
kb_split.py、fastgpt_chat.py 从 B 篇、A 篇复制
docs/ 修改后的示例文档
test_eval.py 3 项测试

需要准备哪些环境?

项目 说明
FastGPT 云服务(cloud.fastgpt.cn / cloud.fastgpt.io)或按官方文档自托管;默认 API 地址 http://localhost:3000/api
API Key 账号 → API 密钥,或 应用 → 发布渠道 → API 访问;形如 fastgpt-xxx
Python 3.9 以上,pip install requests pytest

关键事实

事实 来源
对话接口 detail=true 时可以拿到引用(quoteList) 官方 对话接口
无效 Key 返回 HTTP 500 + code 514 我实测(2026-10-21)
知识库搜索测试接口 searchTest 支持 embedding、fullTextRecall、mixedRecall 三种模式和最低相关度 similarity 官方 知识库接口

文件结构

83-fastgpt-C-src/
├── eval_app.py
├── questions.csv
├── mock_fastgpt.py
├── kb_split.py
├── fastgpt_chat.py
├── docs/
│   ├── IT指南.md
│   └── 员工手册.md
└── test_eval.py

第 1 步:评测题

question,must_include,expected_source
员工年假有几天?,10 天,员工手册.md
出差住宿一线城市每晚报销上限是多少?,500,员工手册.md
报销最晚多久内提交?,30 天,员工手册.md
电脑丢了怎么办?,24 小时,IT指南.md
公司有健身房吗?,,

第 2 步:评测脚本

"""用一组问题回归测试 FastGPT 应用:回答是否包含关键信息、引用是否来自预期文件、
没有答案的问题是否老实说不知道。结果写 eval_report.csv。"""
import csv, os, sys
import fastgpt_chat as f

REFUSAL = ("不知道", "没有找到", "无法回答", "未提及", "没有相关")


def judge(row, answer, quotes):
    sources = {q["source"] for q in quotes}
    problems = []
    if row["must_include"]:
        if row["must_include"] not in answer:
            problems.append(f"回答缺少“{row['must_include']}”")
        if row["expected_source"] and row["expected_source"] not in sources:
            problems.append(f"引用里没有 {row['expected_source']}(实际:{sorted(sources) or '无'})")
    else:   # 知识库里没有答案的问题
        if not any(w in answer for w in REFUSAL):
            problems.append("知识库没有答案,但应用没有说不知道")
    return problems


def main(path="questions.csv"):
    base = os.environ.get("FASTGPT_BASE_URL", "http://localhost:3000/api")
    key, app = os.environ["FASTGPT_API_KEY"], os.environ["FASTGPT_APP_ID"]
    rows, failed = [], 0
    for row in csv.DictReader(open(path, encoding="utf-8")):
        try:
            d = f.call(base, key, app, row["question"])
            ans, qs = f.answer_of(d), f.quotes_of(d)
            probs = judge(row, ans, qs)
        except f.FastGPTError as e:
            ans, qs, probs = "", [], [f"调用失败:{e}"]
        failed += bool(probs)
        print(("✗" if probs else "✓"), row["question"], ";".join(probs))
        rows.append({"question": row["question"], "answer": ans, "sources": ";".join(sorted({q["source"] for q in qs})),
                     "ok": not probs, "problems": ";".join(probs)})
    with open("eval_report.csv", "w", newline="", encoding="utf-8-sig") as fp:
        w = csv.DictWriter(fp, fieldnames=list(rows[0])); w.writeheader(); w.writerows(rows)
    print(f"{len(rows) - failed}/{len(rows)} 通过,明细见 eval_report.csv")
    return 1 if failed else 0


if __name__ == "__main__":
    sys.exit(main(*sys.argv[1:]))

第 3 步:本地 mock(我写的)

"""本地假 FastGPT(我写的,仅用于演示评测脚本,不是 FastGPT 的实现)。
响应结构照官方文档 detail=true、stream=false 示例。检索是最朴素的“字符重合度”,回答直接拼接最相关片段。"""
import json, pathlib, sys
from http.server import BaseHTTPRequestHandler, HTTPServer
sys.path.insert(0, str(pathlib.Path(__file__).parent))
from kb_split import split_markdown

CHUNKS = [(p.name, c) for p in sorted(pathlib.Path("docs").glob("*.md"))
          for c in split_markdown(p.read_text(encoding="utf-8"))]
KEY = "fastgpt-local-mock"


def search(q, k=2):
    grams = {q[i:i + 2] for i in range(len(q) - 1)}
    scored = sorted(((sum(g in c for g in grams), n, c) for n, c in CHUNKS), reverse=True)
    return [(n, c) for s, n, c in scored[:k] if s >= 1]


class H(BaseHTTPRequestHandler):
    def do_POST(self):
        if self.headers.get("Authorization") != f"Bearer {KEY}":
            return self._json(500, {"code": 514, "statusText": "unAuthApiKey", "message": "common:code_error.error_message.514", "data": None})
        body = json.loads(self.rfile.read(int(self.headers["Content-Length"])))
        q = body["messages"][-1]["content"]
        hits = search(q)
        answer = hits[0][1].split("\n", 1)[1] if hits else "知识库中没有找到相关内容。"
        self._json(200, {"id": "", "model": "", "usage": {"prompt_tokens": 0, "completion_tokens": 0, "total_tokens": 0},
            "choices": [{"message": {"role": "assistant", "content": answer}, "finish_reason": "stop", "index": 0}],
            "responseData": [{"moduleName": "知识库搜索", "quoteList": [{"id": str(i), "q": c, "a": "", "sourceName": n} for i, (n, c) in enumerate(hits)]}]})

    def _json(self, code, obj):
        b = json.dumps(obj, ensure_ascii=False).encode()
        self.send_response(code); self.send_header("Content-Type", "application/json"); self.end_headers(); self.wfile.write(b)

    def log_message(self, *a):
        pass


if __name__ == "__main__":
    HTTPServer(("127.0.0.1", 3300), H).serve_forever()

第 4 步:测试

from eval_app import judge

ROW = {"question": "年假几天", "must_include": "10 天", "expected_source": "员工手册.md"}


def test_pass():
    assert judge(ROW, "每年 10 天。", [{"source": "员工手册.md"}]) == []


def test_wrong_source_and_missing_fact():
    p = judge(ROW, "请查看员工手册。", [{"source": "IT指南.md"}])
    assert len(p) == 2 and "IT指南.md" in p[1]


def test_unanswerable_must_refuse():
    row = {"question": "健身房", "must_include": "", "expected_source": ""}
    assert judge(row, "知识库中没有找到相关内容。", []) == []
    assert judge(row, "公司一楼有健身房。", []) == ["知识库没有答案,但应用没有说不知道"]

第 5 步:运行

$ python mock_fastgpt.py &      # 监听 127.0.0.1:3300
$ export FASTGPT_BASE_URL=http://127.0.0.1:3300/api FASTGPT_API_KEY=fastgpt-local-mock FASTGPT_APP_ID=demo

# 第 1 轮:用 B 篇原始文档(21:29 UTC+8)
$ python eval_app.py
✗ 员工年假有几天? 回答缺少“10 天”;引用里没有 员工手册.md(实际:['IT指南.md'])
✓ 出差住宿一线城市每晚报销上限是多少?
✓ 报销最晚多久内提交?
✗ 电脑丢了怎么办? 回答缺少“24 小时”;引用里没有 IT指南.md(实际:无)
✓ 公司有健身房吗?
3/5 通过,明细见 eval_report.csv

# 第 2 轮:删掉转介段落和重复段落,补上“丢了”的说法
✗ 员工年假有几天? 回答缺少“10 天”;引用里没有 员工手册.md(实际:无)
✓ …(其余 4 题通过)
4/5 通过

# 第 3 轮:mock 检索阈值从 2 改为 1
✓ 员工年假有几天? 
✓ 出差住宿一线城市每晚报销上限是多少? 
✓ 报销最晚多久内提交? 
✓ 电脑丢了怎么办? 
✓ 公司有健身房吗? 
5/5 通过,明细见 eval_report.csv

# Key 错误时
$ FASTGPT_API_KEY=wrong python eval_app.py | tail -2
✗ 公司有健身房吗? 调用失败:HTTP 500 code=514 unAuthApiKey:API Key 无效(unAuthApiKey)
0/5 通过,明细见 eval_report.csv
$ python -m pytest -q
3 passed

怎么检查结果?

  • 换成真实应用:把 FASTGPT_BASE_URL 等指向你的 FastGPT,评测题换成真实业务问题(至少 20 道,覆盖常见问法和“没有答案”的问题)。
  • 每次改知识库、提示词、模型后都跑一遍,对比通过数。
  • must_include 只是粗检查:回答“不是 10 天”也包含“10 天”。抽查 CSV 里的原文。

调试示例

1. 转介段落抢走了检索。 第 1 轮“员工年假有几天?”引用的是 IT 指南里的“年假相关问题请查看员工手册,IT 不处理年假申请。”——它含有“年假”,和问题字面更接近,但没有答案。真实知识库里这类“详见某某文档”的段落很多。处理:删掉只做转介的段落,必要时把内容合并到权威文档里。顺手删掉了 B 篇查出的重复段落。

2. 同义说法匹配不上。 “电脑丢了怎么办?”没有命中“丢失电脑须在 24 小时内报告”。我在原文里补了“(丢了、被偷)”。FastGPT 的向量检索对同义词会比我的 mock 好,但口语问法和书面文档之间的差距是真实存在的,官方的 QA 拆分或自定义索引也能解决这个问题。

3. 第 2 轮年假题没有命中任何片段。 删掉转介段落后,“员工年假有几天”和“入职满一年后,每年享有 10 天带薪年假”只有“年假”一个二字组重合,低于 mock 的阈值 2。我把阈值改成 1 后 5/5。要说明:这一步修的是我的 mock,不是知识库。真实 FastGPT 里对应的是应用的“最低相关度”设置,调低会召回更多,也会召回更多无关内容,要用评测题来权衡。

常见问题

现象 原因 处理
全部“调用失败” Key、地址或 appId 错误 先用 A 篇脚本单独问一题
通过率忽高忽低 模型回答有随机性 把应用温度调低,每题跑 2–3 次
引用对了但回答错了 模型没用好引用 改应用提示词,要求只根据引用回答
“不知道”题被判失败 回答用了其他说法 往 REFUSAL 里补充该说法

模板规则

  • 评测题要包含“没有答案”的问题。
  • 同时检查回答和引用来源。
  • 调用失败和答错分开统计。
  • 每次修改后都重跑。

项目资源

用途 地址
FastGPT OpenAPI 说明(官方) https://doc.fastgpt.io/zh-CN/openapi/intro
FastGPT 对话接口(官方) https://doc.fastgpt.io/zh-CN/openapi/chat
FastGPT GitHub https://github.com/labring/FastGPT
FastGPT 许可证 https://github.com/labring/FastGPT/blob/main/LICENSE
FastGPT 知识库接口(官方) https://doc.fastgpt.io/zh-CN/openapi/dataset

有了固定的评测题,改知识库就不再靠感觉。