做一个 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 |
工具调用的关键不在于让模型调用工具,而在于它调用错了、调用个不停的时候,程序能兜住。