AI 工具

Kimi:做一个 HR 小助手:问“张三还剩几天年假”,模型通过 tool_calls 调用本地工具查数据

做一个 HR 小助手:问“张三还剩几天年假”,模型通过 toolcalls 调用本地工具查数据,程序执行后用 role=tool 把结果发回去,模型再组织回答。剩余天数由工具用代码算好,计算器用 AST 解析、只允许四则运算,同时防止模型重复调用、陷入死循环。

BINBHO · 图文教程

做一个 HR 小助手:问“张三还剩几天年假”,模型通过 tool_calls 调用本地工具查数据,程序执行后用 role=tool 把结果发回去,模型再组织回答。剩余天数由工具用代码算好,计算器用 AST 解析、只允许四则运算,同时防止模型重复调用、陷入死循环。

先看清成品

  • 测试: pytest 3 项通过。
  • 本地 qwen3 0.6B 实测:
    • “张三和李四的剩余年假加起来一共多少天?”:先后查了张三(11 天)和李四(10 天),回答 21 天,正确。
    • “王五还剩几天年假?”:查到“没有员工 王五”,回答查不到。
  • 防死循环: 第一版的防重复机制在一次运行中让模型连续 5 轮重复同一个调用,直到触发轮数上限,已修复(见调试示例)。

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

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

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

额外提醒:员工数据属于个人信息。真实系统里,工具要按提问人的权限返回数据,比如员工只能查自己的。不要把整张表塞给模型。

做完之后你会得到什么?

成果 说明
kimi_agent.py 两个工具(查员工、计算器);工具调用循环;保留 reasoning_content;重复调用检测;轮数上限
leave.json 虚构的两名员工
test_tools.py 年假规则、计算器拒绝执行恶意表达式、员工不存在

需要准备哪些环境?

项目 要求 说明
Python 3.9 以上 pip install openai pytest
模型 Kimi API(例如 kimi-k2.6),或支持工具调用的本地模型 box 上 qwen2.5 1.5B 始终不输出 tool_calls,所以改用 qwen3 0.6B

官方文档要点:每个 tool_call 都要回一条带 tool_call_id 的 role=tool 消息;思考模型做工具调用时,assistant 消息里的 reasoning_content 要原样保留在 messages 里。

文件结构

42-kimi-B-src/
├── kimi_agent.py
├── leave.json
└── test_tools.py

第 1 步:代码

kimi_agent.py:

"""工具调用(tool_calls)示例:HR 助手。模型决定调用哪个工具,程序在本地执行,再把结果用 role=tool 发回。
工具:查员工假期数据(本地 leave.json)、安全计算器(只允许四则运算)。
带思考的模型(如 kimi-k2.6 开启思考)做工具调用时,assistant 消息里的 reasoning_content 要原样留在 messages 里。
环境变量:MOONSHOT_API_KEY、KIMI_BASE_URL(默认 https://api.moonshot.cn/v1)、KIMI_MODEL(默认 kimi-k2.6)
用法:python kimi_agent.py "张三今年还剩几天年假?" """
import ast, json, operator, os, sys
from datetime import date
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")
DATA = json.load(open(os.path.join(os.path.dirname(__file__), "leave.json"), encoding="utf-8"))

def get_employee(name):
    if name not in DATA: return {"error": f"没有员工 {name}"}
    d = DATA[name]; years = (date.today() - date.fromisoformat(d["入职日期"])).days / 365.25
    quota = 0 if years < 1 else (10 if years < 5 else 15)          # 规则来自 42A 的示例员工手册
    # 剩余天数直接由工具算好返回:能用代码算的就不要交给模型
    return {**d, "司龄(年)": round(years, 1), "年假额度": quota, "剩余年假": quota - d["今年已休年假"]}

OPS = {ast.Add: operator.add, ast.Sub: operator.sub, ast.Mult: operator.mul, ast.Div: operator.truediv}
def calculate(expression):
    def ev(n):
        if isinstance(n, ast.Constant) and isinstance(n.value, (int, float)): return n.value
        if isinstance(n, ast.BinOp) and type(n.op) in OPS: return OPS[type(n.op)](ev(n.left), ev(n.right))
        raise ValueError("只支持数字和 + - * /")
    try: return {"result": ev(ast.parse(expression, mode="eval").body)}
    except Exception as e: return {"error": str(e)}

TOOLS = [
    {"type": "function", "function": {"name": "get_employee", "description": "查询员工的入职日期、司龄、年假额度和今年已休年假天数",
     "parameters": {"type": "object", "properties": {"name": {"type": "string", "description": "员工姓名"}}, "required": ["name"]}}},
    {"type": "function", "function": {"name": "calculate", "description": "计算四则运算表达式,例如 10-4",
     "parameters": {"type": "object", "properties": {"expression": {"type": "string"}}, "required": ["expression"]}}},
]
IMPL = {"get_employee": get_employee, "calculate": calculate}

def run(question, max_rounds=5):
    messages = [{"role": "system", "content": "你是 HR 助手。涉及员工数据必须调用工具查询,涉及计算必须调用 calculate,不要心算。"},
                {"role": "user", "content": question}]
    seen, repeated = set(), False
    for _ in range(max_rounds):
        # 出现过重复调用就不再提供工具,逼模型根据已有结果作答(只靠错误提示,小模型仍可能一直重复)
        extra = {} if repeated else {"tools": TOOLS}
        msg = client.chat.completions.create(model=MODEL, messages=messages, temperature=0, **extra).choices[0].message
        if not msg.tool_calls: return msg.content
        a = {"role": "assistant", "content": msg.content or "", "tool_calls": [tc.model_dump() for tc in msg.tool_calls]}
        rc = getattr(msg, "reasoning_content", None)
        if rc: a["reasoning_content"] = rc                     # 思考模式 + 工具调用:必须回传
        messages.append(a)
        for tc in msg.tool_calls:
            try: args = json.loads(tc.function.arguments or "{}")
            except json.JSONDecodeError: args = {}
            fn, key = IMPL.get(tc.function.name), (tc.function.name, json.dumps(args, sort_keys=True))
            if key in seen: repeated = True; result = {"error": "这个调用刚才已经做过,结果不会变,请直接根据已有结果回答用户"}
            else: result = fn(**args) if fn else {"error": f"没有工具 {tc.function.name}"}
            seen.add(key)
            print(f"  → {tc.function.name}({args}) = {result}")
            messages.append({"role": "tool", "tool_call_id": tc.id, "content": json.dumps(result, ensure_ascii=False)})
    return "超过最大轮数,停止(防止死循环)"

if __name__ == "__main__":
    print(run(sys.argv[1]))

leave.json:

{"张三": {"入职日期": "2020-03-01", "今年已休年假": 4}, "李四": {"入职日期": "2025-06-15", "今年已休年假": 0}}

test_tools.py:

import os; os.environ.setdefault("MOONSHOT_API_KEY", "test")
from kimi_agent import calculate, get_employee

def test_calculator_is_safe():
    assert calculate("10-4") == {"result": 6}
    assert "error" in calculate("__import__('os').system('ls')")

def test_unknown_employee():
    assert "error" in get_employee("王五")

def test_remaining_days_computed_by_tool():
    d = get_employee("张三"); assert d["剩余年假"] == d["年假额度"] - d["今年已休年假"]

第 2 步:运行

$ KIMI_BASE_URL=http://127.0.0.1:11434/v1 KIMI_MODEL=qwen3-local:0.6b python kimi_agent.py "张三和李四的剩余年假加起来一共多少天?"
  → get_employee({'name': '张三'}) = {'入职日期': '2020-03-01', '今年已休年假': 4, '司龄(年)': 6.6, '年假额度': 15, '剩余年假': 11}
  → get_employee({'name': '李四'}) = {'入职日期': '2025-06-15', '今年已休年假': 0, '司龄(年)': 1.3, '年假额度': 10, '剩余年假': 10}
张三和李四的剩余年假加起来一共21天。

$ python kimi_agent.py "王五还剩几天年假?"
  → get_employee({'name': '王五'}) = {'error': '没有员工 王五'}
  → get_employee({'name': '王五'}) = {'error': '这个调用刚才已经做过,结果不会变,请直接根据已有结果回答用户'}
根据现有信息,无法查询到王五的年假剩余天数。请提供更多信息以便协助您。

(司龄是按运行当天 2026-10-21 计算的。)

调试示例

1. 减法交给谁做。 第一版的工具只返回“额度”和“已休”,剩下的减法要么让模型心算,要么让它再调用一次计算器,多一步就多一个出错的机会。现在工具直接返回“剩余年假”:能用代码算的,就不要交给模型。

2. 一直重复调用。 王五查不到时,模型会一遍又一遍地调用 get_employee("王五")。第一版只是返回一条“已经调用过了”的错误提示,结果有一次运行连续 5 轮都在重复调用,最后输出“超过最大轮数,停止”。现在的做法是:一旦检测到重复调用,下一轮请求就不再提供 tools,模型只能根据已有结果回答。连跑 3 次都正常结束。

常见问题

现象 原因 处理
模型从不调用工具 模型不支持工具调用,或能力不够 换模型(实测 qwen2.5 1.5B 不调用)
400:缺少 tool 消息 某个 tool_call 没有对应的 role=tool 回复 每个 tool_call 都要回复
思考模型做多轮工具调用时报错 丢掉了 reasoning_content 原样保留

模板规则

  • 计算交给工具,模型只负责组织语言。
  • 工具调用的轮数要有上限,并检测重复调用。
  • 工具要按提问人的权限返回数据。

项目资源

用途 地址
工具调用指南 https://platform.kimi.com/docs/guide/use-kimi-api-to-complete-tool-calls
思考模型 https://platform.kimi.com/docs/guide/use-thinking-models
错误码 https://platform.kimi.com/docs/api/errors

工具调用的关键不在于让模型调用工具,而在于它调用错了、调用个不停的时候,程序能兜住。