这是我第一次从零手写一个 AI Agent 的过程记录。 代码:github.com/jeremyhxx/a…
先说 OpenClaw 这件事。
本质就是在本地跑一个 AI Agent,帮你处理邮件、执行任务、接管各种工作流。中文社区叫"养龙虾",最近刷屏了。
我没有直接装。我想先搞清楚这类东西里面是什么——Agent 循环到底长什么样,工具调用是怎么工作的,LLM 在哪个位置做判断。最直接的方法是自己写一遍。
用 Claude Code 配合 Anthropic SDK,针对一个具体需求,手写了一个本地 AI Agent。这篇是过程记录。
需求是真实的
家人要从新加坡飞珀斯,日期定了,机票还没买。来回价格差挺大,不知道什么时候入手合适。
这事儿完全可以自己盯着 Google Flights 刷。但正好想找个真实场景动手——不是价格低于阈值就发通知的那种脚本,而是能看趋势、给判断、说出理由的那种。有真实需求比较好,不然很容易做着做着就失去动力。
不用框架的原因
Agent 循环本身不复杂,理论上都知道:LLM 决定调哪个工具 → 执行 → 结果喂回去 → 循环。LangChain 这类框架、Anthropic 自己的 Agent SDK,都是在封装这个东西。
但知道是一回事,真正写出来又是另一回事。我想直接用 Anthropic SDK 手写这个循环,不套框架——不是觉得框架不好,而是想感受一下每个决策点实际长什么样:工具怎么定义、结果怎么传、循环什么时候停。这些细节在框架里都帮你处理掉了,一开始用框架就不会遇到。
实际写完,核心循环大概长这样:
def run_agent(user_request: str):
messages = [{"role": "user", "content": user_request}]
while True:
response = client.messages.create(
model=MODEL, tools=TOOL_SCHEMAS, messages=messages
)
messages.append({"role": "assistant", "content": response.content})
if response.stop_reason == "end_turn":
break # LLM 决定结束
# 执行 LLM 请求的工具
tool_results = []
for block in response.content:
if block.type == "tool_use":
result = TOOL_MAP[block.name](**block.input)
tool_results.append({
"type": "tool_result",
"tool_use_id": block.id,
"content": json.dumps(result),
})
# 把结果喂回去,进入下一轮
messages.append({"role": "user", "content": tool_results})
就这些。while True 加上 stop_reason == "end_turn" 的退出条件,中间是工具执行和结果回传。框架封装的也是这个,只是加了更多错误处理、重试、状态管理。
Agent 运行时的终端输出
工具设计:这里想了比较久
Agent 能做什么,完全由你给它定义哪些工具决定。
我最开始想了个很懒的方案:一个工具叫"分析机票并给建议",把所有事情都扔给它。这几乎等于没有 Agent,就是一个带 LLM 的函数调用。
后来拆成了 4 个:
search_flights:查当前价格get_price_history:看历史记录analyze_trend:算统计数据(均价、涨跌幅这些)send_recommendation:生成最终建议,推送通知
拆开之后,LLM 自己会决定顺序——它可以先查历史再查当前,也可以反过来。这才有一点"自主"的感觉。
有一个细节想多说一句:analyze_trend 只返回数字和统计,不做判断。判断在 send_recommendation 里——LLM 自己写出"现在买还是再等"的理由。这是我觉得最有意思的部分:数学归代码做,结论归 LLM 做。把这两件事混在一起,就很难说清楚"智能"在哪里。
历史数据谁来写
这个问题卡了我一会儿。
get_price_history 要读历史数据,那这些数据是谁往里写的?
最直觉的想法:加一个 save_price 工具,让 Agent 调完价格之后自己决定保存。
然后我想了一下:如果 LLM 某次"忘了"调这个工具怎么办?历史数据就会有缺口,而且你完全不知道缺在哪。
最后的处理是:search_flights 内部自动把结果追加写入本地 JSON,不通过 LLM,不需要额外调用。Agent 不感知这件事,get_price_history 只是读文件。
后来觉得这背后有一个更通用的判断:"要不要查价格"让 LLM 决定没问题,"查完要不要保存"不应该让 LLM 决定。前者是策略,后者是机制,机制要在代码层保证,不能靠 prompt。
Mock 数据给了我虚假的安全感
架构搭好之后,用模拟数据跑了一遍,一切正常,感觉很好。
然后换成真实请求:
python main.py "帮我查新加坡到珀斯的往返机票,出发 2026-11-28,返程 2026-12-07"
直接报错。
两个问题同时出现:城市映射表里没有"珀斯"(只写了中日韩几个热门城市),而且压根没考虑过往返票,参数里没有 return_date。
修起来不难,都在工具实现层,Agent 循环一行没动。但这件事印象很深——Mock 环境里你只会遇到你预料到的情况。真实数据会带着你没想到的边界一起来。
为什么用 Telegram 不用邮件
跑完分析打印到终端没什么意义,要有推送通知才实用。
选项有几个:macOS 系统通知(只在电脑前有用)、邮件(配置麻烦,收到了也不一定马上看)、Bark(只支持 iOS)、Telegram Bot。
Telegram 赢在几个地方:在新加坡直接能用;手机电脑同步;消息格式比邮件灵活;以后如果想做成"回复消息触发新查询"也天然支持。
配置完之后家里人也想收通知,建了个 Telegram 群,把 Bot 加进去,把 TELEGRAM_CHAT_ID 换成群组 ID(群组 ID 是负数,这个我当时不知道)。代码没动一行。
踩的坑:getUpdates 的 URL 格式是 api.telegram.org/bot<TOKEN>/getUpdates,那个 bot 前缀必须有,没有就 404。来回找了挺久才发现。
Telegram 收到的分析通知
我为什么没用 Claude Code 的定时任务
Agent 跑通之后,下一个问题是:怎么让它每天自动跑,不用手动触发。
Claude Code 有个 Scheduled Tasks 功能,可以配置定时运行,界面很方便,就直接设了。
然后发现:它其实是让 Claude 来"操作电脑运行脚本"。每次运行需要授权 Python 环境,消耗 Claude 的 API token,等于专门请了个人来帮你按回车键。
我的脚本本身已经是一个完整的 Agent 了,再用 Claude 包一层,有点绕。
换成 cron,三行配置,运行时不需要任何人在场,零额外 token。
不是说 Scheduled Tasks 不好,它应该是给"需要 Claude 来推理的定时任务"准备的,比如每周帮你总结邮件、生成报告这种。用它来跑一个已经封装好的脚本,真的用错了。
后来加了一个 bot.py
cron 跑得好好的,但有个场景它解决不了:临时想查一次,不想等下一个定时周期,也不想开终端。
顺手加了个 bot.py——在 Telegram 里发 /查 帮我查新加坡到东京往返,下个月出发,Agent 直接跑,结果回到同一个群。
技术上不复杂,就是 Telegram 长轮询。识别到 /查 前缀,把后面的文本直接传给 run_agent(),LLM 自己理解查询内容,不需要手动解析指令格式。
有意思的是:最初 main.py 里有一个硬编码的 DEFAULT_REQUEST,固定查新加坡到珀斯。加了 bot 之后这个设计就不合适了——接口既然开放了,就应该接受任意输入。改完之后两个入口共用同一个 run_agent(request) 函数,cron 传固定请求,bot 传用户输入,架构没动。
写完之后再看 OpenClaw
自己写过一遍之后,再看 OpenClaw,会发现其实覆盖了同样的组件:
| OpenClaw | 我的版本 |
|---|---|
| Brain(ReAct 循环) | agent.py 的 while 循环 |
| Skills(工具系统) | tools.py |
| Gateway(多渠道消息路由) | bot.py |
| Heartbeat(定时任务) | cron |
| Memory(持久化上下文) | price_history.json |
核心循环是一样的——LLM 决定调哪个工具 → 执行 → 结果喂回去 → 继续。OpenClaw 开源,代码都在那里,不是看不见,只是用的时候不需要感知。
但这个对比只在原理层面成立。OpenClaw 解决了大量我完全没碰到的工程问题:多用户并发、权限隔离、skill 沙箱执行、跨平台渠道接入、错误恢复……我的版本是单人使用、单一场景,把所有复杂度都省掉了。
不过正因为自己写过一遍,知道循环里每个位置在做什么,"这个 skill 在循环的哪个位置介入,它拿到了什么上下文"。这算是这次折腾最实在的收获。
代码在这里,有 .env.example,照着配就能跑:github.com/jeremyhxx/a…
下一篇打算用 Claude Cowork 做同一件事,完全不写代码,看看体验有什么不一样。