改了知识库或提示词以后,原来答对的问题可能就答错了。 这篇做
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 |
有了固定的评测题,改知识库就不再靠感觉。