这是「8天Java后端工程师转AI Agent」系列的第二篇。上一篇(Day 0)把环境和第一次 API 调用跑通了:https://blog.csdn.net/ASIA_kobe/article/details/161839219

我是一个工作8年的Java工程师,之前所有的工作都在 JVM、分布式、服务治理、中间件这一层。这个系列记录我从零开始、把 AI Agent 从概念学到能跑出一个自己用得上的小工具的全过程。


一、这一篇要跑通什么

一句话:让模型自己决定"我需要去查数据",调一个工具拿到真实数据,再基于数据回答。

对比一下你就懂它的价值了:

  • 普通聊天:你问"AAPL 现在估值怎么样",模型凭训练时的记忆随口给你一个数——大概率是编的、过时的。
  • Agent:模型意识到"我不知道最新价格",主动调一个 get_quote 工具去拿真实数据,拿到后再回答。

后者就是 ReAct。这一篇我们不上任何框架,用几十行 Python 手写这个循环。手写一遍,是你后面用任何框架(LangGraph 之类)的底气——因为你知道框架在帮你藏掉什么。


二、ReAct 是什么

ReAct = Reasoning + Acting,来自 2022 年的论文《ReAct: Synergizing Reasoning and Acting in Language Models》。

核心是一个循环:

用户提问
    ↓
[推理 Reason] 模型想:"我需要 AAPL 的最新价,光靠记忆不行"
    ↓
[行动 Act]    模型决定调用 get_quote("AAPL")
    ↓
[观察 Observe] 拿到工具返回 {price: 195.3, pe: 31.2, ...}
    ↓
[再推理]      模型想:"数据够了,可以回答了"
    ↓
最终答案

之前的模型要么"光想不做"(思维链,但不能调工具),要么"做但不想"(直接生成一个调用,没有推理)。ReAct 第一次把两者交替起来。

对后端工程师的类比:这就是一个 while 循环里的状态机——每一轮要么"继续调工具",要么"结束返回"。你天天写这种东西。


三、几个必须先建立的概念

在看代码前,先把 4 个词对齐,不然代码看不懂。

1. Tool / Function Calling

你把工具的「名字、用途、参数」用 JSON Schema 描述给模型。模型推理时如果决定要调,会返回一个结构化的 tool_call 对象(不是在文本里写"请帮我调 xx"),里面有函数名和参数。

关键认知:模型并不是"真的调用"了什么。它只是生成了一个"我想调这个函数、传这些参数"的请求,然后你的代码去真正执行,再把结果塞回去。模型全程碰不到你的函数。

对后端工程师:工具就是"带自然语言文档的接口定义"。你天天在干这事,只不过这次文档是给模型读的。

2. Messages 数组(对话状态机)

模型是无状态的——每次 API 调用都要把完整对话历史重传一遍。messages 数组就是这个状态机,里面有 4 种角色:

role 谁说的 例子
system 你给模型的指令 “你是研究助手…”
user 用户输入 “AAPL 怎么样?”
assistant 模型回复(可能含 tool_calls) “我要调 get_quote”
tool 工具执行结果 {price: 195.3, ...}

Agent 的所有"记忆"和"上下文",物理上就是这个数组。

3. tool_call_id

模型一次推理可以并行返回多个工具调用,每个有独立的 id。把工具结果塞回 messages 时,必须用对应的 id 配对,否则模型分不清"这个返回是哪次调用的结果"。这是手写 ReAct 最容易写错的细节。

4. max_iter(最大循环次数)

模型可能死循环调工具(参数错→失败→换参数→还失败…)。max_iter 是兜底的保险丝。简单任务 3–5 够用。


四、完整代码

先准备好一个假的行情工具(这一篇专注理解循环,不接真 API):

"""
Day 1: Hand-rolled ReAct single agent.
Core loop: reason -> call tool -> observe result -> reason again
"""
import json
from openai import OpenAI

client = OpenAI(
    api_key="你的key",
    base_url="你的endpoint",   # 用官方就删掉这行
)
MODEL = "gpt-4o-mini"   # 或你 endpoint 支持的模型名


# ====== 工具实现(假数据,专注看循环) ======
def get_quote(ticker: str) -> dict:
    """返回某标的的行情类指标(模拟数据)"""
    fake_db = {
        "AAPL": {"price": 195.3,  "pe": 31.2, "change_1y_pct":  24.5, "currency": "USD"},
        "TSLA": {"price": 178.6,  "pe": 65.4, "change_1y_pct":  -8.3, "currency": "USD"},
        "NVDA": {"price": 1180.0, "pe": 72.1, "change_1y_pct": 198.0, "currency": "USD"},
    }
    key = ticker.upper()
    if key in fake_db:
        return {"ticker": key, **fake_db[key]}
    return {"error": f"unknown ticker: {ticker}"}

工具的「接口契约」——这段 schema 就是给模型看的接口文档:

TOOLS = [
    {
        "type": "function",
        "function": {
            "name": "get_quote",
            "description": (
                "Fetch the latest quote for an asset: price, P/E ratio, "
                "and 1-year percentage change. "
                "Ticker formats: US assets use the bare symbol (e.g. AAPL, NVDA)."
            ),
            "parameters": {
                "type": "object",
                "properties": {
                    "ticker": {"type": "string", "description": "Ticker symbol, e.g. AAPL"},
                },
                "required": ["ticker"],
            },
        },
    }
]

TOOL_IMPL = {"get_quote": get_quote}   # 工具名 -> 真实函数

核心:ReAct 主循环:

def run_agent(user_query: str, max_iter: int = 5) -> str:
    messages = [
        {
            "role": "system",
            "content": (
                "You are a research assistant. "
                "Whenever you need price, PE, or performance data, you MUST call "
                "the get_quote tool instead of relying on memory. "
                "Keep final answers concise and structured."
            ),
        },
        {"role": "user", "content": user_query},
    ]

    for step in range(1, max_iter + 1):
        print(f"\n========== iteration {step} ==========")
        resp = client.chat.completions.create(
            model=MODEL,
            messages=messages,
            tools=TOOLS,
            temperature=0.2,
        )
        msg = resp.choices[0].message

        # 情况 A:模型不再调工具 -> 给出最终答案,退出循环
        if not msg.tool_calls:
            print("[final answer produced]")
            return msg.content or ""

        # 情况 B:模型要调工具 -> 执行 + 把结果喂回去
        messages.append(msg.model_dump(exclude_unset=True))

        for call in msg.tool_calls:
            name = call.function.name
            args = json.loads(call.function.arguments)
            print(f"[tool call] {name}({args})")

            impl = TOOL_IMPL.get(name)
            result = impl(**args) if impl else {"error": f"unknown tool: {name}"}
            print(f"[tool result] {result}")

            # 工具结果以 role=tool 的消息塞回,用 tool_call_id 配对
            messages.append({
                "role": "tool",
                "tool_call_id": call.id,
                "content": json.dumps(result, ensure_ascii=False),
            })

    return "(max iterations reached; possible loop)"


if __name__ == "__main__":
    question = "How does AAPL look right now in terms of valuation and 1-year performance? Give a short take."
    print(f"User query: {question}")
    answer = run_agent(question)
    print("\n========== final answer ==========")
    print(answer)

五、跑一遍,看清每一步

运行输出(关键在过程,不在最终文字):

User query: How does AAPL look right now ...

========== iteration 1 ==========
[tool call] get_quote({'ticker': 'AAPL'})
[tool result] {'ticker': 'AAPL', 'price': 195.3, 'pe': 31.2, 'change_1y_pct': 24.5, 'currency': 'USD'}

========== iteration 2 ==========
[final answer produced]

========== final answer ==========
Here's the quick snapshot on Apple:
- Price: $195.30
- P/E: 31.2x — a premium multiple, above the S&P 500 average
- 1-Year Change: +24.5% — outpacing the broader market
...

看清楚发生了什么:

第 1 轮:模型推理 -> 决定调 get_quote("AAPL")
        -> 工具返回真实数据
第 2 轮:模型基于工具结果 -> 不再调工具 -> 给出最终答案

三个关键观察:

  1. 模型没有凭记忆瞎编——它没有直接说"AAPL 大概 200 块",而是先调了工具。
  2. 循环自然停止——第 2 轮模型自己判断"数据够了",不再返回 tool_calls,循环结束。这个"模型自己决定何时停"就是 Agent 的自主性,也是它和写死步骤的普通脚本的本质区别。
  3. 整个 Agent 就这几十行——没有框架,全是你自己写的。你现在完全看得清循环里发生的每一步。

六、自己动手玩这几个变体(比看十遍都管用)

亲手改一下,体感立刻不一样:

1. 问一个数据库里没有的标的

question = "How does MSFT look?"   # 假数据库里没有 MSFT

看模型怎么处理 error 返回——是老实说"查不到",还是硬编一个数?

2. 问一个需要连续调两次的问题

question = "Compare AAPL and NVDA — which looks more reasonably valued?"

看模型会不会一次并发调两个工具——这是 ReAct 真正"活起来"的瞬间。

3. 故意把工具描述写差
description 改成只写 "Get stock data.",看模型会不会传错参数格式、或者干脆不调。这能让你直观感受 tool description 是 prompt engineering 的隐藏战场

4. 问一个根本不需要工具的问题

question = "What is a P/E ratio?"

看模型有没有自知之明——直接回答,而不是画蛇添足去调工具。


七、Day 1 关键词速查

关键词 一句话
ReAct 推理 → 行动(调工具)→ 观察 → 再推理,直到给答案
Function Calling 模型返回"想调哪个函数、传什么参",你的代码去真正执行
Tool Schema 工具的自然语言接口契约(name + description + 参数)
Messages 数组 Agent 的全部记忆和上下文,物理上就是这个列表
tool_call_id 工具结果塞回时和调用配对,不能错
max_iter 死循环的兜底保险丝
自主性 模型自己决定"要不要调工具、什么时候停"——Agent 区别于普通脚本的本质

八、下一篇预告:Day 2 - 多工具与筛选

Day 1 只有一个工具,模型没得选。Day 2 挂上 3 个工具(行情、基本面、新闻),让模型在多个工具间正确选择、组合调用,完成一个"单工具搞不定"的筛选任务——比如"帮我筛出基本面达标、且近期没有重大利空的标的"。

到时候你会第一次看到模型一次并发触发 10 个工具调用,也会第一次直观感受到"对话历史滚雪球、token 成本翻倍"这个后面要反复对付的问题。


配套阅读

  • ⭐ ReAct 原始论文(Yao et al., 2022)— https://arxiv.org/abs/2210.03629
    看懂那张"推理+行动"交替的图,核心思想就到手了。
  • ⭐ Lilian Weng《LLM Powered Autonomous Agents》— https://lilianweng.github.io/posts/2023-06-23-agent/
    Agent 领域最经典的综述,「规划 / 记忆 / 工具使用」三大件讲得最透。

系列后续更新于:https://blog.csdn.net/ASIA_kobe?type=blog
欢迎同样在转 AI 路上的同行点关注,我们一起把这 8 天走完。

Logo

小龙虾开发者社区是 CSDN 旗下专注 OpenClaw 生态的官方阵地,聚焦技能开发、插件实践与部署教程,为开发者提供可直接落地的方案、工具与交流平台,助力高效构建与落地 AI 应用

更多推荐