手写 Agentic Loop:用 while 循环把函数调用变成真正的 Agent(LLM Zoomcamp 实战)
2026/9/16 16:22:59 网站建设 项目流程

手写 Agentic Loop:用 while 循环把函数调用变成真正的 Agent(LLM Zoomcamp 实战)

【免费下载链接】llm-zoomcampLLM Zoomcamp - a free online course about real-life applications of LLMs. In 10 weeks you will learn how to build an AI system that answers questions about your knowledge base. Register here 👇🏼项目地址: https://gitcode.com/GitHub_Trending/ll/llm-zoomcamp

在 LLM Zoomcamp 的 2026 课程中,上一课 13-function-calling.md 演示了"手动"函数调用:发送一条消息、拿到一次函数调用、执行、把结果发回去、再拿到答案。这种方式对单次调用可行,但当模型想要连续搜索多次、或第一次搜索没命中时就会失效——因为我们事先无法知道模型到底想调用几次工具。本篇以课程文档《The Agentic Loop》为核心,手把手用while循环把函数调用串成完整的 Agent 循环,并对照仓库源码(agents.ipynb、rag_helper.py)说明其底层原理。读完你不仅能写出可复用的agent_loop函数,还能理解所有 Agent 框架(LangChain、PydanticAI、OpenAI Agents SDK 等)背后隐藏的同一套模式。

为什么需要 Agentic Loop:单次函数调用的局限

在手动函数调用场景中,我们完整地经历了"模型决定调用search→ 我们执行搜索 → 把结果送回 → 模型给出最终答案"的完整回合,整个流程被拆成了两次 API 调用。这正是普通 RAG 和 Agent 的分水岭:RAG 管道是固定的——搜索、构建 prompt、调用 LLM,开发者提前定死了步骤,搜索永远只跑一次,且永远使用用户的原始 query;Agent 则把 LLM 放到驾驶位——由它决定何时搜索、搜索什么、何时停止。

手动方式的问题在于:它只适用于"恰好一次函数调用"的场景。而真实场景中:

  • 模型可能想连续搜索多次;
  • 第一次搜索可能因为错别字(如 "Olama" vs "Ollama")或措辞偏差而一无所获;
  • 模型需要根据第一次搜索结果调整关键词再做第二次搜索。

这些情况我们无法在写代码时预知调用次数。Agentic Loop 的答案就是:一个持续调用模型、执行工具、直到模型主动停下来的循环。Agent 本质上就是这个循环。

在仓库中可以看到这条演进线索:11-agents-intro.md 说明了为什么要从固定管道转向 Agent,13-function-calling.md 教会了单回合的函数调用机制,而本篇的 Agentic Loop 正是把单回合推广到多回合。

Agent 的三要素:指令、工具、记忆

让 LLM 坐上驾驶位,我们就得到了一个 Agent——一个以帮助用户为目标的 AI 助手。它由三个部分组成:

要素作用在代码中的形态
Instructions(指令)定义 Agent 的角色与行为方式,指令质量直接决定助手的表现developer角色消息传入
Tools(工具)Agent 可以调用的函数,用于执行具体任务本课程中只有search
Memory(记忆)消息历史,Agent 靠它知道自己已经尝试过什么每次追加 prompt、模型输出与工具结果的消息列表

值得注意的是"记忆"的实现方式:LLM 在两次 API 调用之间是无状态的,所谓记忆就是每次请求时通过input传入的完整消息列表。Agent 每执行一步,我们都把这一轮的输出追加进去,下一次调用就能看到之前的所有尝试。下面所有代码都是把这三个要素在一个循环里串起来的实现。

编写 Developer 提示词:先给 Agent 定义角色

到目前为止,是否搜索、搜什么完全由模型自己揣摩。为了让行为更可靠,我们用一条developer消息把期望的行为明确写出来,这也是给 Agent 赋予"角色"的地方。这条消息同时会推动模型进行多次搜索,这样我们就能在运行循环时观察到多轮迭代:

instructions = """ You're a course teaching assistant. You're given a question from a course student and your task is to answer it. If you want to look up information, use the search function. Use as many keywords from the user question as possible when making first requests. Make multiple searches. Try to expand your search by using new keywords based on the results you get from the search. At the end, ask if there are other areas that the user wants to explore. """.strip()

这段提示词包含了几个关键设计:

  • 角色设定:"course teaching assistant",让模型进入助教身份;
  • 首次搜索策略:"Use as many keywords from the user question as possible",鼓励首次搜索尽量穷尽原问题中的关键词;
  • 多轮搜索策略:"Make multiple searches" 与 "expand your search by using new keywords based on the results",明确要求模型根据结果扩展关键词;
  • 收尾行为:最后询问用户是否还有其他想探索的领域,这是课程助手应有的交互习惯。

在仓库的 agents.ipynb 中,这段提示词作为instructions变量原样出现,并被放进{'role': 'developer', 'content': instructions}消息中。这也呼应了 rag_helper.py 里RAGBase的做法——把instructions作为developer消息、prompt作为user消息传给模型,只是 RAG 场景下指令偏向"只依据 CONTEXT 回答"。

函数调用辅助函数:make_call

循环里会反复执行函数调用,因此我们把"解析参数 → 调用函数 → 序列化结果"封装成一个小助手。目前只有一个工具,所以直接按函数名分发:

def make_call(call): args = json.loads(call.arguments) if call.name == "search": result = search(**args) result_json = json.dumps(result, indent=2) return { "type": "function_call_output", "call_id": call.call_id, "output": result_json, }

这个助手返回的正是 Responses API 期待的结构:type固定为function_call_outputcall_id把工具结果与模型请求的特定函数调用关联起来(如果一轮中模型发起了多个函数调用,每个都有自己的call_id),output是序列化后的搜索结果 JSON。以后每增加一个工具,只需在这个函数里加一个if分支(或改用注册表分发机制)。在 agents.ipynb 中,make_call的实现与本课文档完全一致。

这里的search函数来自上一课,直接查询 minsearch 索引(见 ingest.py 中的build_index):

def search(query): boost_dict = {"question": 3.0, "section": 0.5} filter_dict = {"course": "llm-zoomcamp"} return index.search( query, num_results=5, boost_dict=boost_dict, filter_dict=filter_dict )

其中boost_dictquestion字段权重(3.0)高于section(0.5),filter_dict把结果限定在当前课程(course: "llm-zoomcamp"),num_results=5控制返回条数——这些参数与 rag_helper.py 中RAGBase.search的默认配置保持一致,可从中推断这是课程统一的检索配置。

处理单次响应:把模型输出与工具结果都追加进对话

现在处理一次模型响应。我们把响应中的每条输出都追加到对话里,打印消息内容,并执行所有函数调用;函数调用的结果同样被追加进对话:

question = "I just discovered the course. Can I join it?" messages = [ {"role": "developer", "content": instructions}, {"role": "user", "content": question}, ] response = openai_client.responses.create( model="gpt-5.4-mini", input=messages, tools=[search_tool], ) messages.extend(response.output) has_function_calls = False for item in response.output: if item.type == "function_call": print("function_call:", item.name, item.arguments) call_output = make_call(item) messages.append(call_output) has_function_calls = True elif item.type == "message": print("ASSISTANT:") print(item.content[0].text)

关键细节解析:

  • messages.extend(response.output)先把模型本次的全部输出(包括函数调用条目)追加进历史——模型需要看到它自己提出的函数调用
  • 遍历response.output时按item.type分派:function_call类型执行工具、message类型打印助手文本;
  • has_function_calls标志记录本轮是否出现了函数调用,它决定是否需要再发起一次 API 请求。

从 agents.ipynb 中可以看到这个流程的实际运行痕迹:模型把 "I just discovered the course. Can I join it?" 改写成了类似join course discovered late can I join enroll late join course的搜索关键词,这说明模型不会原样照搬用户问题,它会自主改写 query 以提高检索命中率。而工具返回的结果是包含idcoursesectionquestionanswer五个字段的 FAQ 条目数组,模型正是基于这些字段组织最终答案。

完整 Agent 循环:while True 直到模型不再调用工具

把上面的处理逻辑包进while循环,循环会持续调用模型,直到它返回一个不含任何函数调用的响应为止。同时维护一个迭代计数器,方便观察发生了多少次往返:

it = 1 while True: print(f"iteration #{it}...") has_function_calls = False response = openai_client.responses.create( model="gpt-5.4-mini", input=messages, tools=[search_tool], ) messages.extend(response.output) for item in response.output: if item.type == "function_call": print("function_call:", item.name, item.arguments) call_output = make_call(item) messages.append(call_output) has_function_calls = True elif item.type == "message": print("ASSISTANT:") print(item.content[0].text) it = it + 1 if has_function_calls == False: break

这就是 Agent 循环的核心:模型负责推理下一步行动,你的代码负责执行,模型在下一轮看到执行结果。当模型返回最终答案、不再请求工具时,循环结束。

关于这个循环,有三点值得深入:

  1. 搜索次数不由我们决定:模型搜几次、搜什么,都由它自己决定,我们只是持续循环直到它停止请求工具;
  2. 退出条件是最简单的一种:本轮没有函数调用即结束。从代码结构看,has_function_calls这个布尔标志就是整个循环的"刹车片";
  3. 生产环境需要安全网:文档明确提示,其他框架会在其上叠加安全措施——最大迭代次数(比如最多 5 轮、最后一轮强制给出答案)、token 预算、墙钟时间限制等,但核心依然是这一个标志位。

在 agents.ipynb 中可以找到这段循环的逐行实现,以及一次真实的运行输出:iteration #1中模型发起了三次search调用(分别为课程加入、新生晚加入、课程访问截止日期等不同关键词),iteration #2中模型直接给出了最终答案——"Yes — you can still join the course. If you want a certificate, make sure to submit your project while submissions are still open..."。

封装为可复用的 agent_loop 函数

把循环包进函数,接受指令和问题作为参数,返回最终答案,这样就能反复使用:

def agent_loop(instructions, question, model="gpt-5.4-mini") -> str: messages = [ {"role": "developer", "content": instructions}, {"role": "user", "content": question} ] it = 1 while True: print(f"iteration #{it}...") has_function_calls = False response = openai_client.responses.create( model=model, input=messages, tools=[search_tool] ) messages.extend(response.output) for item in response.output: if item.type == "function_call": print("function_call:", item.name, item.arguments) call_output = make_call(item) messages.append(call_output) has_function_calls = True elif item.type == "message": print("ASSISTANT:") last_answer = item.content[0].text print(item.content[0].text) it = it + 1 if has_function_calls == False: break return last_answer

注意与裸循环的两处差异:model作为可配置参数(默认gpt-5.4-mini),以及在message分支里用last_answer记录最后的文本输出,供函数返回。

用带错别字的问题试试:

agent_loop(instructions, "How do I run Olama locally?")

观察运行过程:Agent 先搜索 "Olama",结果很差;随后它用 "Ollama" 再次搜索并找到了答案。循环让模型自己从一次糟糕的搜索中恢复过来——这正是走向 Agentic 的全部意义。在 agents.ipynb 中可以看到同样的恢复过程:第一轮搜索 "Olama locally run install local FAQ" 返回的多是无关条目,第二轮搜索 "Ollama run llama3 local server localhost:11434 FAQ" 才命中 Ollama 安装 FAQ,最终给出分步骤的完整回答。

再试试课程报名问题:

agent_loop(instructions, "I just discovered the course. Can I still join it?")

用指令鼓励多次搜索

这里有一个微妙的问题:模型经常在第一次搜索后就给出答案,即使更多搜索会更有帮助——它认为自己知道得够多了。为了推动它更深入探索,我们改写指令:

instructions = """ You're a course teaching assistant. You're given a question from a course student and your task is to answer it. If you want to look up information, use the search function. Use as many keywords from the user question as possible when making first requests. Make multiple searches. First perform search, analyze the results and then perform more searches. At the end, ask if there are other areas that the user wants to explore. """.strip() agent_loop(instructions, "I just discovered the course. Can I join it?")

关键改动在 "Make multiple searches. First perform search, analyze the results and then perform more searches."——明确要求先搜索、分析结果、再继续搜索。改完后,Agent 会针对每个问题发起多次搜索,而不是在第一轮结果后就收手。

指令是我们操控 Agent 的主要手段,但要清醒认识到:模型有时仍会跳过某些步骤,不要指望它每次运行都严格照做。在 agents.ipynb 的运行记录中可以看到这一版指令的实际效果:报名问题上,iteration #1搜索了 "join course discovered course can I join enrollment late joining FAQ",iteration #2搜索了 "new student can I join course after start FAQ enrollment" 与 "course access enrollment deadline can join FAQ",三轮搜索后才进入最终回答阶段。

限制话题范围:轻量级的输入护栏

目前的 Agent 有问必答。问它国际象棋的事,它照样会尝试回答:

agent_loop(instructions, "what's queen gambit?")

但我们想要的是课程助手,而不是通用聊天机器人。于是收紧指令,让 Agent 只从 FAQ 回答问题。对我们的自有场景,让它基于通用知识作答或许也无妨,所以这里主要作为"如何通过指令划定范围"的示例:

instructions = """ You're a course teaching assistant. You're given a question from a course student and your task is to answer it. If you want to look up information, use the search function. Use as many keywords from the user question as possible when making first requests. Make multiple searches. First perform search, analyze the results and then perform more searches. The question has to be about the course or its logistics, offtopic questions shouldn't be answered. If the search returns nothing, it's likely an off-topic question. If you can't answer the question using FAQ, don't do it yourself. Only use the facts from the FAQ database. At the end, ask if there are other areas that the user wants to explore. """.strip() agent_loop(instructions, "what's queen gambit?")

新增的两条规则非常关键:

  • 范围判定:"The question has to be about the course or its logistics, offtopic questions shouldn't be answered. If the search returns nothing, it's likely an off-topic question."——用"搜索无结果"作为离题问题的信号;
  • 禁止自由发挥:"If you can't answer the question using FAQ, don't do it yourself. Only use the facts from the FAQ database."——防止模型用通用知识编造答案。

在 agents.ipynb 中可以找到这版指令的实测输出:Agent 先搜索 "queen gambit",再搜索 "gambit chess opening queen's gambit course FAQ",随后回答 "I couldn't find a course FAQ entry for 'queen gambit,' so it looks like this may be off-topic for the course.",并且明确表示 "I can't answer outside the course FAQ"。

这其实就是一种轻量级的输入护栏(input guardrail):通过指令告诉 Agent 什么在范围内、什么不在。真正的护栏会在 Agent 运行之前检查输入,直接拦截离题问题——那是另一个主题,但指令是入手的第一步。

手写循环的意义:所有框架都隐藏着同一个模式

这个手写循环是理解框架背后机制的最佳途径。每一个 Agent 框架——无论是 LangChain、PydanticAI,还是 OpenAI Agents SDK——本质上都包装了同样的模式:while True循环调用模型、处理函数调用、把工具结果加回消息历史、直到模型停止请求工具。

下一课 15-frameworks.md 正好印证了这一点:课程引入的 ToyAIKit 库"做的事情和我们的手写循环一样,但样板代码更少",并且明确说明"如果你打开它的runners代码,会找到我们手写的一模一样的while True循环"。区别只在于:框架把make_call的分发、消息管理、迭代安全网都替你封装好了,还顺带统计了 token 用量与成本。

成本提醒

每次循环迭代都是一次付费 API 调用,而且后续调用会把完整历史作为输入重发,所以越到后面输入 token 越多、单次成本越高。上一课 13-function-calling.md 中专门演示了如何读取response.usageinput_tokensoutput_tokens)并结合每百万 token 单价估算成本——真实 Agent 循环可能发起多次调用,开发时务必留意usage字段。

小结

本文从"单次函数调用不够用"出发,完整走过了 Agentic Loop 的构建历程:

  1. 三要素:指令(developer消息)、工具(search)、记忆(消息历史);
  2. 辅助函数make_call把函数调用转换成 Responses API 期望的function_call_output结构;
  3. 循环本体while True反复调用模型、执行工具、追加历史,直到has_function_calls为 False;
  4. 封装复用agent_loop(instructions, question, model)一行即可运行,错别字问题("Olama" → "Ollama")被模型自主修复;
  5. 指令即控制:通过改写指令推动多次搜索、收紧话题范围,实现轻量级输入护栏。

配套的完整可运行代码见 agents.ipynb,检索基础与 RAG 基类见 ingest.py 与 rag_helper.py。理解了这一个循环,你就拿到了阅读任何 Agent 框架源码的钥匙。

【免费下载链接】llm-zoomcampLLM Zoomcamp - a free online course about real-life applications of LLMs. In 10 weeks you will learn how to build an AI system that answers questions about your knowledge base. Register here 👇🏼项目地址: https://gitcode.com/GitHub_Trending/ll/llm-zoomcamp

创作声明:本文部分内容由AI辅助生成(AIGC),仅供参考

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询