AI 工具

把公司手册、报销规定这类 PDF 交给 Kimi 的文件接口:上传、抽取文字、作为 system 消息提问

把公司手册、报销规定这类 PDF 交给 Kimi 的文件接口:上传、抽取文字、作为 system 消息提问。要求回答必须以“依据:「原文」”结尾,程序再到文件原文里逐字核对这句话。找不到原文就标记“不可信”。用完自动删除上传的文件。

BINBHO · 图文教程

把公司手册、报销规定这类 PDF 交给 Kimi 的文件接口:上传、抽取文字、作为 system 消息提问。要求回答必须以“依据:「原文」”结尾,程序再到文件原文里逐字核对这句话。找不到原文就标记“不可信”。用完自动删除上传的文件。

先看清成品

  • 测试: pytest 4 项通过,覆盖引文核对的各种情况。
  • 端到端: 在模拟服务器加本地 qwen2.5 1.5B 上跑通。
  • 最新一次运行: 答案是对的(700 元,并且知道新规定替代旧规定),但只写了文件名、没有引用原文,程序标记为“不可信”。
  • 更早的一次运行: 模型编了一个原文里没有的“900 元”,也被程序拦下了。

没有在 box 上跑的部分: box 上没有 Moonshot API Key,没有调用过 Kimi 官方 API。

  • 用到的替身: 文中的 KIMI_BASE_URL 指向两种替身。
    • 本地 Ollama 的 OpenAI 兼容接口。
    • A 篇自己写的 fake_moonshot.py。它是我按官方文档写的模拟服务器,只模拟了文件上传、抽取、删除这几个接口:用 pdftotext 抽取文字,对话转发给本地 Ollama。
  • 结论: 下面的成功和失败都是本地小模型的表现,不代表 Kimi 本身的效果。

合规提醒:Kimi API Key 只放在环境变量里。上传的文件会存到 Kimi 开放平台的账号下,含有个人信息、合同、病历等敏感内容的文件,要先确认公司的规定和对方是否同意。用完的文件及时删除(本文的代码会自动删除)。模型的回答只能作为参考,涉及报销、人事、法律的结论以原文件和负责人的确认为准。价格以官网“定价”页为准,本文不列价格。

做完之后你会得到什么?

成果 说明
kimi_fileqa.py 上传(purpose=file-extract)→ 取抽取的文字 → 每个文件单独放一条 system 消息 → 提问 → 核对引文 → 删除文件
fake_moonshot.py 模拟服务器(127.0.0.1:18100),仅用于离线测试
test_quote.py 引文核对:真引文通过、编造的不通过、太短的不通过、用文件名冒充的不通过

需要准备哪些环境?

项目 要求 说明
Python 3.9 以上 pip install openai pytest(模拟服务器还需要 flask 和 poppler-utils)
Kimi 开放平台的 API Key MOONSHOT_API_KEY;base_url https://api.moonshot.cn/v1
测试文件 两份虚构的规定 员工手册(住宿 600 元)和补充说明(自 2026-09-01 起,一线城市 700 元、其他城市 450 元)

官方文档的要点(截至本文写作时):上传时用 purpose="file-extract";用 files.content(file_id).text 取抽取的文字;每个文件放一条 system 消息;账号下的文件数量和单个文件大小都有上限,具体数字见文档。

文件结构

42-kimi-A-src/
├── kimi_fileqa.py
├── fake_moonshot.py
└── test_quote.py

第 1 步:代码

kimi_fileqa.py:

"""多文件问答:按 Kimi 官方“文件问答”指南,用 purpose="file-extract" 上传文件、取回抽取后的文本,
每个文件单独放进一条 system 消息,再提问。问完删除上传的文件(账号的文件总数有上限)。
环境变量:MOONSHOT_API_KEY、KIMI_BASE_URL(默认 https://api.moonshot.cn/v1)、KIMI_MODEL(默认 kimi-k2.6)
用法:python kimi_fileqa.py "问题" 文件1.pdf [文件2.pdf ...] [--keep 不删除上传的文件]"""
import os, sys
from pathlib import Path
from openai import OpenAI

client = OpenAI(base_url=os.environ.get("KIMI_BASE_URL", "https://api.moonshot.cn/v1"),
                api_key=os.environ.get("MOONSHOT_API_KEY") or "missing-key")
MODEL = os.environ.get("KIMI_MODEL", "kimi-k2.6")
SYSTEM = ("你是文档问答助手。只根据下面提供的文件内容回答;文件之间有冲突时,以日期更新的为准,并说明依据哪个文件。"
          "文件里没有的信息,直接说“文件中没有提到”。"
          "回答最后另起一行,写“依据:「……」”,引号里原样摘抄你所依据的那句原文,一个字都不要改。")

def check_quote(answer, texts):
    """程序核对“依据”里的原文是否真的出现在文件中,防止模型编造。返回 (是否通过, 引文)"""
    import re
    m = re.search(r"依据[::].*?[「“\"](.+?)[」”\"]", answer, re.S)   # 小模型常把「」写成“”
    if not m: return False, None
    if len(m[1]) < 8: return False, m[1]          # 太短的“引文”(比如只写了文件名)不算依据
    norm = lambda s: re.sub(r"\s+", "", s)
    return any(norm(m[1]) in norm(t) for t in texts), m[1]

def file_messages(paths):
    msgs, ids, texts = [], [], []
    for p in paths:
        fo = client.files.create(file=Path(p), purpose="file-extract")
        ids.append(fo.id)
        text = client.files.content(file_id=fo.id).text
        texts.append(text)
        msgs.append({"role": "system", "content": f"文件《{Path(p).name}》的内容:\n{text}"})
        print(f"✓ 已上传并抽取 {Path(p).name}({fo.id},{len(text)} 字符)")
    return msgs, ids, texts

def main():
    args = [a for a in sys.argv[1:] if a != "--keep"]
    question, paths = args[0], args[1:]
    msgs, ids, texts = file_messages(paths)
    try:
        r = client.chat.completions.create(model=MODEL, messages=[{"role": "system", "content": SYSTEM}, *msgs,
                                                                  {"role": "user", "content": question}])
        answer = r.choices[0].message.content.strip()
        print("\n" + answer)
        ok, quote = check_quote(answer, texts)   # 只和文件原文比对,不含我们加的“文件《…》的内容”标题
        print("\n✓ 引文核对通过:原文里确实有这句话" if ok else
              f"\n⚠ 引文核对失败:{'没有给出依据' if quote is None else '文件里找不到这句原文'},这个回答不可信,请人工查看原文件")
        u = r.usage; print(f"\n(输入 {u.prompt_tokens} tokens,输出 {u.completion_tokens} tokens)")
    finally:
        if "--keep" not in sys.argv:
            for i in ids: client.files.delete(file_id=i)
            print(f"已删除 {len(ids)} 个上传文件")

if __name__ == "__main__":
    main()

fake_moonshot.py(仅用于离线测试):

"""本地测试用的假 Moonshot 服务:实现 files 上传/抽取/删除接口(用 pdftotext 抽取),
chat 请求转发给本地 Ollama。只用于没有 API key 时验证程序流程,不代表 Kimi 的真实效果。
用法:python fake_moonshot.py   (监听 127.0.0.1:18100;FAKE_MODEL 指定转发到的 Ollama 模型)"""
import os, subprocess, tempfile, uuid
import requests
from flask import Flask, jsonify, request

app, FILES = Flask(__name__), {}
OLLAMA, MODEL = "http://127.0.0.1:11434/v1", os.environ.get("FAKE_MODEL", "qwen2.5-local:1.5b")

@app.post("/v1/files")
def upload():
    f = request.files["file"]
    if request.form.get("purpose") not in ("file-extract", "image", "video", "batch"):
        return jsonify(error={"type": "invalid_request_error", "message": f"Invalid purpose: {request.form.get('purpose')}"}), 400
    with tempfile.NamedTemporaryFile(suffix=".pdf") as t:
        f.save(t.name); text = subprocess.run(["pdftotext", "-layout", t.name, "-"], capture_output=True, text=True).stdout
    fid = "fake-" + uuid.uuid4().hex[:12]; FILES[fid] = (f.filename, text)
    return jsonify(id=fid, object="file", bytes=len(text), created_at=0, filename=f.filename, purpose="file-extract", status="ok")

@app.get("/v1/files/<fid>/content")
def content(fid):
    # 官方文档:返回 text/plain 的 Markdown 文本(不是 JSON)
    return FILES[fid][1], 200, {"Content-Type": "text/plain; charset=utf-8"}

@app.delete("/v1/files/<fid>")
def delete(fid):
    FILES.pop(fid, None); return jsonify(id=fid, object="file", deleted=True)

@app.post("/v1/chat/completions")
def chat():
    body = request.get_json(); body["model"] = MODEL
    return requests.post(f"{OLLAMA}/chat/completions", json=body, timeout=600).json()

if __name__ == "__main__":
    app.run(port=18100)

test_quote.py:

import os; os.environ.setdefault("MOONSHOT_API_KEY", "test")
from kimi_fileqa import check_quote
T = ["自 2026 年 9 月 1 日起,出差住宿标准调整为:一线城市每晚不超过 700 元,其他城市不超过 450 元。"]

def test_real_quote_passes():
    assert check_quote("700 元\n依据:「一线城市每晚不超过 700 元」", T)[0]

def test_invented_quote_fails():
    assert not check_quote("900 元\n依据:「一线城市每晚不超过 900 元」", T)[0]
    assert check_quote("900 元", T) == (False, None)

def test_curly_quotes_accepted():
    assert check_quote("依据:《报销补充说明.pdf》中“一线城市每晚不超过 700 元”", T)[0]

def test_too_short_quote_is_not_evidence():
    assert not check_quote("依据:「700 元」", T)[0]

第 2 步:运行

# 真实使用
$ export MOONSHOT_API_KEY=sk-...
$ python kimi_fileqa.py "2026年10月出差住宿每晚最多报销多少?" 员工手册.pdf 报销补充说明.pdf
# box 上的离线测试:先启动 python fake_moonshot.py,再设置 KIMI_BASE_URL=http://127.0.0.1:18100/v1
✓ 已上传并抽取 员工手册.pdf(fake-ed5efadd5ba0,306 字符)
✓ 已上传并抽取 报销补充说明.pdf(fake-0860e0302342,87 字符)

依据:《报销补充说明.pdf》

2026年10月出差住宿最高最多可报销700元,因为文件明确规定自2026年9月1日起,出差住宿标准为一线城市每晚不超过700元,其他城市不超过450元。

⚠ 引文核对失败:没有给出依据,这个回答不可信,请人工查看原文件

(输入 437 tokens,输出 64 tokens)
已删除 2 个上传文件

检查点:这次的数字其实是对的,但模型没有按要求给出原文引用,程序照样判为不可信。这正是这道检查的作用:程序不判断答案对不对,只核对有没有原文依据。

调试示例

1. 编造的数字。 有一次运行,1.5B 模型回答“900 元”,并附上了一段看起来像原文的“依据”。check_quote 在原文里找不到这句话,判为不可信。没有这道检查的话,这个错误的答案就直接交给用户了。

2. 模拟服务器写错了。 我第一版的 fake_moonshot.py 在 /files/{id}/content 返回的是 JSON。重读官方文档后发现,真实接口返回的是抽取出来的纯文本(files.content(id).text),我改了模拟服务器。教训:模拟服务器是按我对文档的理解写的,本身也可能有错,上线前一定要用真实 API 再跑一遍。

3. 文件名“冒充”了引文。 我给每条 system 消息加了一个标题“文件《员工手册.pdf》的内容”。模型把“依据:「文件《员工手册.pdf》的内容」”当作引文,第一版核对时连标题一起比对,结果误判为通过。改成只和文件原文比对之后才正确,test_quote.py 里有对应的测试。

常见问题

现象 原因 处理
上传失败,提示文件数量超限 账号下的文件总数有上限 用完就删;本文代码在 finally 里删除
扫描版 PDF 抽取出来是空的 图片里没有文字层 先做 OCR,或者使用视觉模型
回答引用了旧规定 新旧规定都在上下文里 提示词里要求“以生效日期最新的为准”,并核对引文
content_filter 错误 内容触发了审核(错误码文档) 检查文件内容

模板规则

  • 回答必须附带原文,由程序逐字核对。
  • 核对只针对文件原文,不包括自己加的标题。
  • 模拟服务器只用于离线开发,上线前必须用真实 API 验证。

项目资源

用途 地址
文件问答指南 https://platform.kimi.com/docs/guide/use-kimi-api-for-file-based-qa
文件接口 https://platform.kimi.com/docs/api/files
错误码 https://platform.kimi.com/docs/api/errors

让模型“拿出原文来”,再用程序核对,是给文档问答加上的最简单、也最有效的一道保险。