《8天Java后端工程师转AI Agent》Day 1:手写第一个 ReAct 单 Agent(不上框架)
这是「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 importjsonfromopenaiimportOpenAI clientOpenAI(api_key你的key,base_url你的endpoint,# 用官方就删掉这行)MODELgpt-4o-mini# 或你 endpoint 支持的模型名# 工具实现假数据专注看循环 defget_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},}keyticker.upper()ifkeyinfake_db:return{ticker:key,**fake_db[key]}return{error:funknown 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 主循环defrun_agent(user_query:str,max_iter:int5)-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},]forstepinrange(1,max_iter1):print(f\n iteration{step})respclient.chat.completions.create(modelMODEL,messagesmessages,toolsTOOLS,temperature0.2,)msgresp.choices[0].message# 情况 A模型不再调工具 - 给出最终答案退出循环ifnotmsg.tool_calls:print([final answer produced])returnmsg.contentor# 情况 B模型要调工具 - 执行 把结果喂回去messages.append(msg.model_dump(exclude_unsetTrue))forcallinmsg.tool_calls:namecall.function.name argsjson.loads(call.function.arguments)print(f[tool call]{name}({args}))implTOOL_IMPL.get(name)resultimpl(**args)ifimplelse{error:funknown tool:{name}}print(f[tool result]{result})# 工具结果以 roletool 的消息塞回用 tool_call_id 配对messages.append({role:tool,tool_call_id:call.id,content:json.dumps(result,ensure_asciiFalse),})return(max iterations reached; possible loop)if__name____main__:questionHow does AAPL look right now in terms of valuation and 1-year performance? Give a short take.print(fUser query:{question})answerrun_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 Heres the quick snapshot on Apple: - Price: $195.30 - P/E: 31.2x — a premium multiple, above the SP 500 average - 1-Year Change: 24.5% — outpacing the broader market ...看清楚发生了什么第 1 轮模型推理 - 决定调 get_quote(AAPL) - 工具返回真实数据 第 2 轮模型基于工具结果 - 不再调工具 - 给出最终答案三个关键观察模型没有凭记忆瞎编——它没有直接说AAPL 大概 200 块而是先调了工具。循环自然停止——第 2 轮模型自己判断数据够了不再返回 tool_calls循环结束。这个模型自己决定何时停就是 Agent 的自主性也是它和写死步骤的普通脚本的本质区别。整个 Agent 就这几十行——没有框架全是你自己写的。你现在完全看得清循环里发生的每一步。六、自己动手玩这几个变体比看十遍都管用亲手改一下体感立刻不一样1. 问一个数据库里没有的标的questionHow does MSFT look?# 假数据库里没有 MSFT看模型怎么处理error返回——是老实说查不到还是硬编一个数2. 问一个需要连续调两次的问题questionCompare AAPL and NVDA — which looks more reasonably valued?看模型会不会一次并发调两个工具——这是 ReAct 真正活起来的瞬间。3. 故意把工具描述写差把description改成只写Get stock data.看模型会不会传错参数格式、或者干脆不调。这能让你直观感受tool description 是 prompt engineering 的隐藏战场。4. 问一个根本不需要工具的问题questionWhat 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?typeblog欢迎同样在转 AI 路上的同行点关注我们一起把这 8 天走完。