用 Claude API 的工具调用做一个命令行记账助手:你说“午饭花了 32 块”,Claude 调用
add_expense写入本地 SQLite;你问“这个月交通花了多少”,它调用sum_expenses查出真实数字再回答,不会自己编。
先看清成品
成品是
ledger_agent.py。本文已用模拟客户端实际运行 3 个测试并全部通过:工具调用结果按正确格式(tool_result+tool_use_id)传回;金额为负、缺少参数时作为错误回传给模型;按日期范围汇总正确。另外用真实 SDK(anthropic 1.17.0)和无效密钥发了一次带工具定义的请求,返回 401invalid x-api-key,说明请求格式被接受。本文环境没有有效的 API 密钥,没有测试真实对话效果。
注意:Claude 只在 Anthropic 官方“支持的国家和地区”列表中提供,请在支持地区合规使用,不要用代理或他人账号绕过限制,也不要共享账号。遵守 Anthropic 的使用政策。价格和用量以官方价格页为准。
做完之后你会得到什么?
| 成果 | 说明 |
|---|---|
| 两个工具 | 记一笔支出、按范围汇总 |
| 工具循环 | 模型要调用就执行,直到给出最终回答 |
| 本地数据 | SQLite,不上传账本 |
| 错误回传 | 参数不对时告诉模型,让它修正 |
| 安全上限 | 每轮最多 5 次工具调用 |
需要准备哪些环境?
| 项目 | 要求 | 说明 |
|---|---|---|
| Claude Console 账号 | 充值或有 API 额度 | API 与 Claude 订阅分开计费 |
| API 密钥 | 保存为 ANTHROPIC_API_KEY |
|
| Python | 3.10 或更高 | |
| 依赖 | pip install anthropic pytest |
本文使用 anthropic 1.17.0 |
| 模型 | claude-sonnet-5-5 |
官方模型页列出的 ID,可用 CLAUDE_MODEL 覆盖 |
第一步:工具调用是怎么工作的?
| 步骤 | 内容 |
|---|---|
| 1 | 请求时带上 tools:名称、描述、input_schema |
| 2 | 模型需要工具时返回 stop_reason: "tool_use" 和 tool_use 内容块 |
| 3 | 你的程序执行函数,把结果作为 tool_result 发回,tool_use_id 要对应 |
| 4 | 模型根据结果继续,直到 stop_reason 不再是 tool_use |
真正读写数据库的是你的代码,模型只决定“调用哪个工具、传什么参数”。
第二步:代码怎么写?
1. 项目结构
ledger/
├── ledger_agent.py
├── test_ledger.py
└── ledger.db # 运行后生成
2. ledger_agent.py
"""用 Claude 工具调用做一个记账助手:说人话记账、查账,数据存在本地 SQLite。
用法:python ledger_agent.py 然后输入“午饭花了 32 块”“这个月餐饮花了多少”
"""
import json
import os
import sqlite3
from datetime import date
MODEL = os.environ.get("CLAUDE_MODEL", "claude-sonnet-5-5")
DB = os.environ.get("LEDGER_DB", "ledger.db")
CATEGORIES = ["餐饮", "交通", "购物", "居住", "娱乐", "其他"]
TOOLS = [
{
"name": "add_expense",
"description": "记录一笔支出。用户说花了多少钱、买了什么时调用。",
"input_schema": {
"type": "object",
"properties": {
"amount": {"type": "number", "description": "金额,单位元,必须大于 0"},
"category": {"type": "string", "enum": CATEGORIES},
"note": {"type": "string", "description": "简短备注,例如“午饭”"},
"day": {"type": "string", "description": "日期 YYYY-MM-DD,用户没说就用今天"},
},
"required": ["amount", "category", "note", "day"],
},
},
{
"name": "sum_expenses",
"description": "按日期范围和可选分类汇总支出,返回总额和笔数。",
"input_schema": {
"type": "object",
"properties": {
"start": {"type": "string", "description": "开始日期 YYYY-MM-DD(含)"},
"end": {"type": "string", "description": "结束日期 YYYY-MM-DD(含)"},
"category": {"type": "string", "enum": CATEGORIES},
},
"required": ["start", "end"],
},
},
]
def conn():
c = sqlite3.connect(DB)
c.execute("CREATE TABLE IF NOT EXISTS exp (id INTEGER PRIMARY KEY, day TEXT, amount REAL, category TEXT, note TEXT)")
return c
def add_expense(amount, category, note, day):
if amount <= 0:
return {"error": "金额必须大于 0"}
if category not in CATEGORIES:
return {"error": f"分类必须是 {CATEGORIES} 之一"}
with conn() as c:
cur = c.execute("INSERT INTO exp (day, amount, category, note) VALUES (?, ?, ?, ?)", (day, amount, category, note))
return {"ok": True, "id": cur.lastrowid}
def sum_expenses(start, end, category=None):
sql, args = "SELECT COALESCE(SUM(amount), 0), COUNT(*) FROM exp WHERE day BETWEEN ? AND ?", [start, end]
if category:
sql += " AND category = ?"
args.append(category)
total, n = conn().execute(sql, args).fetchone()
return {"total": round(total, 2), "count": n}
HANDLERS = {"add_expense": add_expense, "sum_expenses": sum_expenses}
def system_prompt():
return f"你是记账助手,今天是 {date.today().isoformat()}。用工具记账和查账,不要自己编造数字。回答简短,用中文。"
def chat(client, messages, max_rounds=5):
"""执行一轮对话:模型要求调用工具就执行,直到它给出最终回答。"""
for _ in range(max_rounds):
resp = client.messages.create(model=MODEL, max_tokens=1024, system=system_prompt(), tools=TOOLS, messages=messages)
messages.append({"role": "assistant", "content": resp.content})
if resp.stop_reason != "tool_use":
return "".join(b.text for b in resp.content if b.type == "text")
results = []
for b in resp.content:
if b.type == "tool_use":
try:
out = HANDLERS[b.name](**b.input)
except Exception as e: # 参数不对时把错误告诉模型,让它修正
out = {"error": str(e)}
print(f" [工具] {b.name}({json.dumps(b.input, ensure_ascii=False)}) -> {json.dumps(out, ensure_ascii=False)}")
results.append({"type": "tool_result", "tool_use_id": b.id, "content": json.dumps(out, ensure_ascii=False),
"is_error": "error" in out})
messages.append({"role": "user", "content": results})
return "(工具调用次数过多,已停止)"
def main():
import anthropic
client = anthropic.Anthropic() # 读取环境变量 ANTHROPIC_API_KEY
messages = []
print("记账助手已启动,输入 q 退出")
while (q := input("你:").strip()) not in ("q", "quit"):
if q:
messages.append({"role": "user", "content": q})
print("助手:" + chat(client, messages))
if __name__ == "__main__":
main()
| 要点 | 说明 |
|---|---|
enum 限定分类 |
模型只能从固定分类中选 |
| 系统提示写今天日期 | “昨天”“这个月”才能换算正确 |
| 函数里再校验一次 | 不完全依赖模型遵守结构 |
is_error |
告诉模型这次调用失败了 |
max_rounds |
防止无限循环 |
第三步:怎样测试和运行?
1. 离线测试
from types import SimpleNamespace as NS
import pytest
import ledger_agent as la
@pytest.fixture(autouse=True)
def tmpdb(tmp_path, monkeypatch):
monkeypatch.setattr(la, "DB", str(tmp_path / "l.db"))
def text(t): return NS(type="text", text=t)
def tool(i, name, args): return NS(type="tool_use", id=i, name=name, input=args)
class FakeClient:
"""按顺序返回预设的模型回复,并记录每次请求"""
def __init__(self, replies):
self.replies, self.calls = list(replies), []
self.messages = NS(create=self._create)
def _create(self, **kw):
self.calls.append(kw)
return self.replies.pop(0)
def test_record_then_answer():
c = FakeClient([
NS(stop_reason="tool_use", content=[text("好的"), tool("t1", "add_expense", {"amount": 32, "category": "餐饮", "note": "午饭", "day": "2026-10-21"})]),
NS(stop_reason="end_turn", content=[text("已记录午饭 32 元。")]),
])
msgs = [{"role": "user", "content": "午饭 32"}]
assert la.chat(c, msgs) == "已记录午饭 32 元。"
assert c.calls[0]["tools"][0]["name"] == "add_expense"
tr = msgs[2]["content"][0]
assert tr["type"] == "tool_result" and tr["tool_use_id"] == "t1" and tr["is_error"] is False
assert la.sum_expenses("2026-10-01", "2026-10-31", "餐饮") == {"total": 32, "count": 1}
def test_bad_args_reported_as_error():
c = FakeClient([
NS(stop_reason="tool_use", content=[tool("t1", "add_expense", {"amount": -5, "category": "餐饮", "note": "x", "day": "2026-10-21"})]),
NS(stop_reason="tool_use", content=[tool("t2", "sum_expenses", {"start": "2026-10-01"})]),
NS(stop_reason="end_turn", content=[text("金额有误")]),
])
msgs = [{"role": "user", "content": "x"}]
la.chat(c, msgs)
assert msgs[2]["content"][0]["is_error"] is True
assert "end" in msgs[4]["content"][0]["content"] # 缺参数的 TypeError 被传回给模型
def test_sum_range():
la.add_expense(10, "交通", "地铁", "2026-09-30")
la.add_expense(20, "交通", "打车", "2026-10-02")
assert la.sum_expenses("2026-10-01", "2026-10-31") == {"total": 20, "count": 1}
python -m pytest -q
本文实际输出 3 passed。
2. 真实运行
export ANTHROPIC_API_KEY="你的密钥"
python ledger_agent.py
期望的对话(具体措辞会不同):
你:午饭花了 32,打车 18
[工具] add_expense({"amount": 32, "category": "餐饮", "note": "午饭", "day": "2026-10-21"}) -> {"ok": true, "id": 1}
[工具] add_expense({"amount": 18, "category": "交通", "note": "打车", "day": "2026-10-21"}) -> {"ok": true, "id": 2}
助手:已记录午饭 32 元、打车 18 元。
你:这个月一共花了多少?
[工具] sum_expenses({"start": "2026-10-01", "end": "2026-10-31"}) -> {"total": 50, "count": 2}
助手:本月共 2 笔,合计 50 元。
3. 用数据库核对
sqlite3 ledger.db "SELECT * FROM exp"
助手说的数字应该和数据库一致。
出问题时该怎么调?
1. 模型没调用工具,直接回答
工具描述写清“什么时候调用”;系统提示里要求“不要自己编造数字”。
2. 报 400,提示 tool_use_id 不匹配
每个 tool_use 都必须有对应的 tool_result,并且放在紧接着的 user 消息里。
3. 日期算错
检查系统提示里的日期;时区不同时,date.today() 用的是本机时区。
常见问题怎么处理?
| 现象 | 原因 | 解决方法 |
|---|---|---|
| 401 | 密钥错误 | 重新复制 |
| 404 模型不存在 | 模型 ID 写错或已下线 | 查官方模型页 |
| 一句话记了两次 | 模型重复调用 | 在工具描述里说明“每笔只调用一次” |
| 费用增长 | 对话历史越来越长 | 定期清空 messages |
| 想加删除功能 | 删除有风险 | 先让程序要求你确认再执行 |
项目资源
建议先跑离线测试理解循环,再用真实密钥记三五笔账,用 sqlite3 核对每个数字。
确认可靠后再增加工具,例如按分类列出明细、设置月度预算提醒。