🧪 Phase 1 通关·5步拆解

从零手写一个单步 AI Agent:Think → Act → Observe → Answer,不靠任何框架,全部自己写。

① 造消息 ② 调LLM ③ 读返回 ④ 执行工具 ⑤ 第二轮
⬇ 下载 phase1-steps.py
1
造消息 build_messages
🎯 目标

写一个函数 build_messages(user_input),把用户的一句话包装成 OpenAI 标准的消息列表,作为后续所有 LLM 调用的"输入原料"。

📖 详细讲解

OpenAI 的消息格式是什么?每条消息就是一个字典,只有两个核心字段:role(角色)和 content(内容),形如 {"role": "user", "content": "你好"}。为什么用这种格式?因为这是行业标准化的约定——LLM 本身不看"谁在键盘上打字",它只看输入序列里每个片段的 role 标记,靠 role 判断"这句话是谁说的",从而决定自己该以什么身份、什么口吻接话。没有这个标记,模型就分不清指令和闲聊。

role 有四种取值:

  • system:系统指令,给 LLM 定规则、定人设,比如"你是一个助手"。它优先级最高,整场对话一直生效。
  • user:用户说的话,即人类输入的问题。
  • assistant:LLM 自己的回复。多轮对话时要把历史回复也喂回去,模型才知道自己之前说过什么。
  • tool:工具执行后返回的结果,步骤4会用到。

为什么用列表 messages 而不是单个字典?因为真实对话是多轮的:用户问一句、LLM 答一句、用户再追问……LLM 本身没有记忆,每次调用都是"无状态"的,它只能看到你这次发过去的全部内容。所以必须用一个有序列表把整段对话历史按时间顺序装起来,LLM 从第一条读到最后一条,才能理解上下文。列表的顺序就是对话的时间线,顺序错了模型就糊涂了。

这一步只需要构造前两条消息:第一条 system 告诉 LLM"它是谁、要遵守什么规则";第二条 user 告诉 LLM"用户问了什么"。这两条是任何一次调用的最小完整输入。后续步骤中,我们会不断往这个列表里 append 新消息——步骤4 会追加 assistant 的工具请求和 tool 的执行结果,列表会像滚雪球一样越滚越长,这就是"上下文积累"的本质。

💻 代码骨架
def build_messages(user_input: str) -> list[dict]:
    # TODO: 返回一个列表,包含两条消息:
    #   1. {"role": "system", "content": "你是一个乐于助人的AI助手"}
    #   2. {"role": "user",   "content": user_input}
    pass
✅ 检查标准
  • test1() 运行通过,无报错
  • 返回的列表长度 = 2
  • 第一条消息的 role 必须是 "system",第二条是 "user"
2
调 LLM call_llm
🎯 目标

写一个函数 call_llm(user_input):用步骤1的消息列表调用 DeepSeek API,把 LLM 的回复文字取出来返回。跑通"发消息 → 收回复"的完整链路。

📖 详细讲解

client 是什么?client = OpenAI(api_key=..., base_url=...) 创建出来的对象,本质是一个和 DeepSeek 服务器通信的 HTTP 客户端。它帮你封装好了认证头、请求格式、重试逻辑等脏活,你只需要调方法、传参数。DeepSeek 兼容 OpenAI 的 API 协议,所以直接用官方 openai 库、把 base_url 指向 DeepSeek 即可。

client.chat.completions.create() 干了什么?它把你传的 messages 等参数打包成一个 JSON 请求体,通过 HTTPS POST 发给 DeepSeek 的服务器;服务器上的大模型读完全部消息、计算生成回复后,把结果打包成 JSON 返回,这个返回值就是 response。一次调用 = 一次完整的网络往返,模型本身不保存任何状态。

关键参数详解:

  • model="deepseek-chat":指定用哪个模型。换成别的模型名就换了个"大脑"。
  • messages=messages:对话历史,就是步骤1造出来的列表。
  • temperature=0.3:控制随机性。范围 0~1,越接近 0 输出越稳定(同样输入几乎得到同样回答),越接近 1 越发散随机。写 Agent 要可预测,所以用低值。

response 的结构:返回的不是纯字符串,而是一个嵌套对象。response.choices 是一个列表,每个元素是一个候选回答;取 choices[0] 拿到第一个候选,再 .message.content 才是 LLM 真正回复的那段文字。层层剥开:response → choices[0] → message → content

为什么用 choices[0]?这是 API 设计留的扩展空间:你可以传 n=3 让模型一次返回 3 个候选答案再挑。但实践中 99% 的情况只要一个回答,所以固定取第一个 choices[0] 就行。

💻 代码骨架
def call_llm(user_input: str) -> str:
    messages = build_messages(user_input)          # 步骤1
    response = client.chat.completions.create(
        model="deepseek-chat",
        messages=messages,
        temperature=0.3,
    )
    # TODO: 返回 response.choices[0].message.content
    pass
✅ 检查标准
  • test2() 返回一个非空字符串
  • 打印出来能看懂——LLM 正常回复了你的问题
3
读返回,判断要不要调工具
🎯 目标

调用 LLM 时带上 tools=TOOLS,然后解析返回:判断 LLM 是"直接回答"还是"请求调用工具",需要工具时把 tool_calls 提取出来备用。

📖 详细讲解

为什么要传 tools=TOOLS?TOOLS 是一个 JSON Schema 列表,每一项描述一个工具:叫什么名字(name)、是干什么的(description)、需要什么参数(parameters)。把它传给 LLM,等于递给模型一张"工具清单":你有这些工具可用、各自怎么用。LLM 不认识你的 Python 代码,它只靠读 description 字段判断"用户这个需求该用哪个工具"——所以 description 写得越清楚,工具选对率越高。

LLM 的两种返回模式:

  • ① 直接回答finish_reason == "stop",且 message.content 有内容。比如用户说"你好",闲聊不需要工具,模型直接把答案写在 content 里,流程到此结束。
  • ② 要求调工具finish_reason == "tool_calls",此时 message.content 通常为空,但 message.tool_calls 有内容——模型说"我答不了,请先帮我执行这些工具"。

message.tool_calls 的结构:它是一个列表(模型可能一次要求调多个工具),每个元素包含:id(这次调用的唯一标识,后面回传结果要靠它配对)、function.name(工具名字符串,如 "calculate")、function.arguments(参数字符串,如 '{"expression":"15*37"}')。

为什么 arguments 是字符串而不是字典?因为 LLM 的输出本质是纯文本流——它不会"生成一个 Python 对象",它只是一个字符一个字符地写出了一段 JSON 文本。所以你的代码拿到手的是字符串,必须用 json.loads() 把它解析成 Python 字典,才能取出里面的 expression 字段去用。

判断逻辑总结:如果 finish_reason == "stop" 且 content 有内容 → 直接返回答案,收工;否则 → 遍历 message.tool_calls,把每个工具调用的 id、name、arguments 提取出来,准备进入步骤4执行。

💻 代码骨架
response = client.chat.completions.create(
    model="deepseek-chat",
    messages=messages,
    tools=TOOLS,              # ← 关键:告诉LLM有哪些工具
    temperature=0.3,
)
choice = response.choices[0]
msg = choice.message

if choice.finish_reason == "stop" and msg.content:
    print("直接回答:", msg.content)
else:
    # TODO: 遍历 msg.tool_calls,打印每个的
    #   id / function.name / function.arguments
    pass
✅ 检查标准
  • 输入 "你好" → 走"直接回答"分支
  • 输入 "15×37等于多少" → 识别出需要调用 calculate 工具,能打印出工具名和参数
4
执行工具,结果回传
🎯 目标

在本地真正执行 LLM 要求的工具函数,把结果按 role="tool" 格式 append 回 messages,让对话列表带上"工具的执行结果"。

📖 详细讲解

一个关键认知:LLM 从来没有碰过外部世界。它不会算数、不会联网、不会读写文件。步骤3里它做的全部事情只是输出一段文本:"我要调 calculate,参数是 15*37"。真正拿起工具、按下开关的,是你写的 Python 代码。Agent 的"行动能力"100%来自你的代码,LLM 只是个"动嘴的指挥官"。

TOOL_MAP 的作用:它是一个字典,负责把 LLM 说的字符串"calculate")映射到你本地的真实 Python 函数TOOL_MAP = {"calculate": calculate}。LLM 说名字,你查字典拿到函数对象。这层映射也是安全闸门——白名单之外的函数 LLM 永远调不到。

json.loads(tc.function.arguments):LLM 给的参数是 JSON 字符串 '{"expression":"15*37"}',用 json.loads 转成 Python 字典 {"expression": "15*37"},代码才能操作它。

func(**tool_args):**字典解包操作符,把字典的每个键值对展开成关键字参数。func(**{"expression": "15*37"}) 完全等价于 func(expression="15*37")。这样无论工具要几个参数,都能用统一的一行代码调用。

结果回传格式详解:执行完后,要把结果 append 成一条这样的消息:{"role": "tool", "tool_call_id": tc.id, "content": json.dumps(result, ensure_ascii=False)}。其中 tool_call_id 必须和 LLM 给的 id 一字不差——因为一次可能有多个工具调用,LLM 第二轮读结果时,全靠这个 id 关联"这个结果对应我上次要求的哪一次调用"。id 对不上,模型就张冠李戴。

ensure_ascii=False:不写它的话,json.dumps 会把中文转义成 \uXXXX 形式的乱码。写上它有三个好处:中文原文更省 token(一个汉字不用占六个字符)、LLM 能直接读懂中文、你自己 print 调试时肉眼可读

messages 变化追踪:初始 2 条(system + user)→ 追加 1 条 assistant 消息(里面装着 tool_call 请求)→ 追加 1 条 tool 消息(执行结果)→ 现在共 4 条。这份越来越长的列表就是 Agent 的"记忆"。

💻 代码骨架
if choice.finish_reason == "stop" and msg.content:
    return msg.content          # 不需要工具,直接收工

# 先把 assistant 的 tool_call 请求原样追加进 messages
messages.append(msg)

for tc in msg.tool_calls:
    func = TOOL_MAP[tc.function.name]                # 字符串→函数
    tool_args = json.loads(tc.function.arguments)     # JSON字符串→字典
    result = func(**tool_args)                        # 字典解包调用
    # TODO: append {"role":"tool", "tool_call_id":tc.id,
    #        "content":json.dumps(result, ensure_ascii=False)}

print("messages 长度:", len(messages))   # 应该是 4
✅ 检查标准
  • 工具被真正执行,result 是正确的计算结果(如 555)
  • messages 从 2 条变成 4 条
  • tool 消息里的 tool_call_id 与 LLM 给的 id 完全一致
5
第二轮 LLM → 最终答案
🎯 目标

把带着工具结果的完整 messages 再发给 LLM 一次,让它基于工具结果组织出最终自然语言答案;并把全流程封装成 run_agent()

📖 详细讲解

此时 messages 里的完整上下文:① system 指令(你是谁)→ ② 用户问题(15×37等于多少)→ ③ LLM 的 tool_call 请求(我要调 calculate)→ ④ 工具执行结果(555)。把这 4 条一起发过去,LLM 从头到尾读一遍,就拥有了回答所需的全部信息,能自然地说出"15×37 = 555"。它不需要重新算,答案已经在第④条里了,它只负责组织语言

第二轮调用的关键区别:不带 tools=TOOLS 参数。为什么?因为工具已经执行完了,任务进入"总结汇报"阶段,不再需要给模型工具清单。不带 tools 还能防止模型再来一轮 tool_call 造成死循环——这轮的职责只有一个:看着结果,说人话。

为什么一个函数能同时处理"需要工具"和"不需要工具"两种情况?if 分支:不需要工具时(finish_reason 是 stop),在步骤4的位置就提前 return,函数直接结束,根本不会走到第二轮调用的代码;需要工具时才执行工具、再走第二轮。两条路径在同一个函数里泾渭分明。

这就是完整的 Think → Act → Observe → Answer 循环:LLM 思考要不要用工具(Think)→ 你的代码执行工具(Act)→ 结果回传、模型观察结果(Observe)→ 模型给出最终答案(Answer)。LangChain、AutoGPT 等所有 Agent 框架,剥掉外壳后核心都是这个循环。你手写通了它,就理解了全部框架的底层。

run_agent() 是对外的统一入口:它把步骤1~5 全部封装起来。从外部看,签名极其简单——字符串进(str),字符串出(str)。调用者完全不用关心里面发生了几轮调用、调没调工具。这就是好的封装:复杂留给内部,简单留给外部。

💻 代码骨架
def run_agent(user_input: str) -> str:
    messages = build_messages(user_input)                 # 步骤1
    response = client.chat.completions.create(           # 步骤2+3
        model="deepseek-chat", messages=messages,
        tools=TOOLS, temperature=0.3)
    choice = response.choices[0]
    msg = choice.message

    if choice.finish_reason == "stop" and msg.content:
        return msg.content      # 不需要工具:直接返回

    messages.append(msg)          # 步骤4:执行工具 + 回传
    for tc in msg.tool_calls:
        result = TOOL_MAP[tc.function.name](**json.loads(tc.function.arguments))
        messages.append({"role": "tool", "tool_call_id": tc.id,
                        "content": json.dumps(result, ensure_ascii=False)})

    # TODO: 步骤5 第二轮调用(不带tools),返回最终 content
    pass
✅ 检查标准
  • 输入 "15×37等于多少" → 返回包含"等于 555"的自然语言答案
  • 输入 "你好" → 直接返回问候语,没有触发任何工具调用
  • 两条路径都跑通 = 🎉 Phase 1 通关!