Day 38 / 共 68 天 · 阶段 6 Agent 核心

手写最小 Agent

昨天(Day 37)攒齐了 state、存档、续跑这些概念。今天是阶段 6 的收官实战:不用任何框架,亲手把前面所有零件拼起来——一个 while 循环 + 一张工具表 + 让 LLM 决定"下一步调哪个工具",约 60 行跑通"想→动手→看→再想"的完整主循环。写完这一个,你就真懂了明天(Day 39)起讲的框架到底在替你做什么。

📍 你在阶段 6(Agent 核心 D33-38)的位置
D33 Agent 是什么 D34 ReAct D35 规划 D36 记忆 D37 状态&断点续跑 D38 手写最小 Agent
💡 用一个类比兜住今天(今天全程沿用「带一个刚来的实习生跑腿」的世界观) 写一个 Agent = 带一个机灵但没经验的实习生办事。LLM(大模型) = 实习生的脑子,会想、会判断,但两手空空、干不了实事;工具表 = 你交给他的一串联系人电话(查天气打这个、算数打那个),他自己不干、只会"说该打哪个";while 主循环 = 你在旁边当调度员:听他说打哪个→你替他拨通→把结果念给他听→他接着想下一步;结束条件 = 他说"齐了,可以回复用户了"。今天你写的这 60 行,本质就是"实习生动嘴、你这个循环替他动手"的调度台。
L01

目标:一个 while 循环就是 Agent 的心脏

🤔 痛点前面天天说"Agent 会自己想、自己调工具、自己判断做完没"。可它到底怎么"自己"?背后是不是有黑魔法?不亲手写一遍,永远隔着一层。
💡 本质没有黑魔法。Agent 的心脏就是一个循环:问 LLM 下一步干啥 → 若说"调某工具"就替它调、把结果塞回去 → 再问 LLM → …… 直到它说"完事"。就是你带实习生:他动嘴指方向,你这个循环替他跑腿、报结果,来回几趟把事办成。
Agent 主循环:想 → 动手 → 看 → 再想 🧠 想LLM 决策 🔧 动手调工具 👀 看结果塞回黑板 循环,直到 LLM 说"完事"
图注:三个动作转圈,就是 Agent。今天我们把这个圈用 Python 一行行写出来。
👶 提前打个预防针下面代码会调真的 LLM API(回忆 Day 11)。没 key 也别怕:每段都有注释,先读懂"这一步在循环里干嘛"最重要,key 配好了照跑即可。
L02

先备好"工具表":实习生的联系人名单

🤔 痛点LLM 只会输出文字,它自己查不了天气、算不了数。得先告诉它"你手上有哪些工具、每个干嘛用",它才知道该指哪个。
💡 本质工具表就是一个字典:名字 → 真正干活的 Python 函数。再配一段文字说明(每个工具叫啥、干嘛、要什么参数),塞进给 LLM 的提示里当"联系人名单"。它只负责说"打 get_weather 这个,参数城市=北京",真正拨号(执行函数)的是你的循环
# 两个最普通的 Python 函数,就是"实习生能打的两个电话"
def get_weather(city):               # 工具1:查天气(这里写死,真实项目会调 API)
    return f"{city} 今天 5℃,有风"

def calculate(expr):                 # 工具2:算数
    return str(eval(expr))           # eval 把 "3*4" 这种字符串当算式算出来

# 工具表:名字 → 函数。循环里靠这张表按名字找到函数去执行
TOOLS = {"get_weather": get_weather, "calculate": calculate}

# 给 LLM 看的"说明书":告诉它有哪些工具、怎么用
TOOLS_DOC = """你可以使用的工具:
- get_weather(city): 查某城市天气,参数 city 是城市名
- calculate(expr): 计算算式,参数 expr 是像 "3*4" 的字符串"""
这里 eval 只为演示,它能执行任意代码、生产环境绝不能直接用(Day 30、Day 54 讲工具安全会细说)。今天先聚焦"循环怎么转"。
L03

让 LLM 做决策:要么"调工具",要么"给答案"

🤔 痛点怎么让 LLM 的回答"机器能读懂"?它要么想调工具、要么想直接回答用户,我的循环得一眼分清是哪种,才知道下一步该拨号还是该收工。
💡 本质约定一个暗号格式:让 LLM 永远回一小段 JSON(回忆 Day 14 结构化输出)。要调工具就回 {"action":"工具名","input":"参数"};能收工就回 {"action":"final","input":"给用户的话"}。这样你的循环 if action=="final" 就收尾,否则就去工具表里找那个工具执行。像实习生只用两种句式汇报:"该打某电话"或"事儿齐了,回复如下"。
📝 举个例子:同一个问题,LLM 的两轮不同回复 用户问"北京天气怎样,该穿厚的吗?":
第 1 轮 LLM 回 → {"action":"get_weather","input":"北京"}(它要先查)
你执行拿到"5℃有风"塞回去,第 2 轮 LLM 回 → {"action":"final","input":"北京5℃有风,建议穿厚外套"}(齐了)。
你的循环靠 action 这个字段,就知道该拨号还是该收工。
import json
from openai import OpenAI          # Day11 学过的 SDK;换 Claude 同理
client = OpenAI()                  # 会自动读环境变量里的 API key

def 问大模型(messages):
    r = client.chat.completions.create(
        model="gpt-4o-mini",       # 便宜够用的小模型
        messages=messages,
        response_format={"type": "json_object"},  # 强制它只输出 JSON(Day14)
    )
    return json.loads(r.choices[0].message.content)   # 解析成字典好判断
L04

while 主循环:调度员开始转圈

🤔 痛点零件齐了(工具表、决策函数),怎么把它们串成"会自己转"的循环?循环里到底每一圈干哪几件事?
💡 本质循环每一圈就四件事:①问 LLM 下一步 → ②看它回的 action:是 final 就跳出返回答案 → ③否则按名字去工具表执行,拿到结果 → ④把"我调了啥、结果是啥"追加进对话历史(Day37 的黑板/追加!),回到 ①。这块对话历史就是昨天说的 state:每圈都往里追加,LLM 每次都看着完整历史再想。
循环一圈里的四件事 ①问 LLM下一步? ②看 actionfinal?→收工 ③执行工具拿结果 ④追加进历史 回到①,直到 action=final
图注:把这四步写成 while,就是一个能自己跑好几圈、自己收尾的 Agent。
L05

完整代码:约 60 行,能跑

💡 本质把 L02~L04 拼起来就是一整个可运行的 Agent。逐行都有注释,慢慢读——你会发现"Agent"祛魅后,朴素得让人安心。
import json
from openai import OpenAI
client = OpenAI()

# ---------- 1) 工具表 ----------
def get_weather(city): return f"{city} 今天 5℃,有风"
def calculate(expr):   return str(eval(expr))
TOOLS = {"get_weather": get_weather, "calculate": calculate}
TOOLS_DOC = """可用工具:
- get_weather(city): 查天气,参数 city
- calculate(expr): 算式计算,参数 expr(如 "3*4")"""

# ---------- 2) 系统提示:教 LLM 用暗号格式回话 ----------
SYSTEM = f"""你是一个助手,能调用工具。{TOOLS_DOC}
每次只回一个 JSON:
- 要调工具:{{"action":"工具名","input":"参数"}}
- 能回答了:{{"action":"final","input":"给用户的话"}}"""

def run(question, max_steps=5):                 # max_steps = 循环上限,防死转
    messages = [{"role":"system","content":SYSTEM},
                {"role":"user","content":question}]   # 这就是 state:对话历史黑板
    for step in range(max_steps):               # ← while 主循环(限次数更安全)
        r = client.chat.completions.create(
            model="gpt-4o-mini", messages=messages,
            response_format={"type":"json_object"})
        reply = json.loads(r.choices[0].message.content)   # ①问 LLM
        messages.append({"role":"assistant","content":json.dumps(reply)})  # 追加它的话

        if reply["action"] == "final":          # ②是 final → 收工返回
            return reply["input"]

        tool = TOOLS.get(reply["action"])        # ③按名字找工具
        result = tool(reply["input"]) if tool else "没有这个工具"  # 执行拿结果
        print(f"  🔧 调用 {reply['action']}({reply['input']}) → {result}")
        messages.append({"role":"user","content":f"工具结果:{result}"})  # ④塞回黑板
    return "步数用尽,没得出最终答案"            # 兜底:超过上限也不无限转

print(run("北京天气怎么样?该穿厚衣服吗?"))
📝 举个例子:跑起来的输出大概长这样
  🔧 调用 get_weather(北京) → 北京 今天 5℃,有风
北京今天 5℃ 还有风,挺冷的,建议穿厚外套 🧥
第一圈 LLM 决定查天气(你替它执行、把结果塞回),第二圈它拿到天气、直接给了 final 答案。整个"自己想、自己调、自己收尾"的过程,就发生在这个 for 循环里。
L06

三道护栏:让循环不失控

🤔 痛点循环最怕两件事:一是无限转(LLM 一直调工具不收尾,烧钱又卡死);二是一步崩全崩(某个工具报错,整个 Agent 挂掉)。
💡 本质给循环加三道护栏,新手实习生也能放心用:①步数上限(上面的 max_steps,转够几圈就强制停);②工具执行包 try/except(回忆 Day05:工具挂了返回"出错了"这段文字,而不是让程序崩,让 LLM 看到错误自己换招);③工具不存在给兜底(LLM 若报了个不存在的工具名,回"没有这个工具"而不是报错)。
# 把 L05 里执行工具那句,升级成带护栏的版本
tool = TOOLS.get(reply["action"])
if tool is None:                          # 护栏③:工具名不存在
    result = f"没有名为 {reply['action']} 的工具"
else:
    try:                                  # 护栏②:执行出错也不崩
        result = tool(reply["input"])
    except Exception as e:
        result = f"工具执行出错:{e}"      # 把错误当结果塞回,让 LLM 换个思路
# 护栏①:for range(max_steps) 天然限制了最多转几圈

👶 小白:这么简陋的东西,也配叫 Agent?跟大厂那些比是不是玩具?

👨‍🏫 老师:它就是真 Agent 的内核,不是玩具的仿制品。LangGraph、CrewAI 那些框架,骨架也是这个循环——只是它们额外帮你处理了状态存档、并行、分支、可视化、重试、监控(正好是明天 Day 39 要讲的"手写的痛点")。你今天亲手写过这 60 行,再看框架就不会懵:哦,这个 API 就是替我做循环里的那一步。会造轮子的人,才用得明白别人的轮子。

L07

今日小结 + 动手 10 分钟

🧠 今天你应该能回答

  • Agent 的"心脏"是什么?(一个循环:问 LLM→调工具→塞结果→再问)
  • "工具表"是什么?LLM 和你的循环各负责哪部分?(它动嘴、你动手)
  • 怎么让循环分清 LLM 是"要调工具"还是"给答案"?(约定 JSON 暗号 + action 字段)
  • 对话历史 messages 和昨天的 state 是什么关系?(它就是那块黑板,每圈追加)
  • 循环要加哪三道护栏?(步数上限 / try-except / 工具不存在兜底)

✋ 动手 10 分钟:给你的 Agent 加第三个工具

把 L05 完整代码敲进 day38.py 跑通后,自己动手加一个新工具,体会"扩展一个 Agent 有多简单"——只要往工具表和说明书里各加一行:

# 1) 写一个新函数
def now_time(_):                 # 参数用不到,写个 _ 占位
    from datetime import datetime
    return datetime.now().strftime("%H:%M")

# 2) 注册进工具表
TOOLS["now_time"] = now_time

# 3) 在 TOOLS_DOC 里加一行说明,让 LLM 知道它存在
#    - now_time(): 返回当前时间,不需要参数

# 然后问它:run("现在几点了?顺便帮我算一下 128 * 7")
# 观察它是不是分两步:先调 now_time,再调 calculate,最后 final 汇总

如果没配 API key,就把 问大模型 那部分注释掉,手动 reply = {"action":"now_time","input":""} 造几组假回复喂给循环,一样能看清循环怎么转。

明日预告 · Day 39:恭喜你手写完了 Agent,阶段 6 通关!但你可能已经隐隐觉得累了:状态要自己存、分支要自己 if、想并行调两个工具还得自己搞、跑挂了想看它每步干了啥也没地方看……明天(阶段 7 开篇)就来算这笔账:手写循环到底有哪些痛点,为什么大家最后都投奔了"编排框架",并引出后面的主角 LangGraph 和 CrewAI。
← Day 37 · 状态 & 断点续跑 Day 39 · 为什么要编排框架 →