公司动态
Agent Harness 工程:从一次 LLM 调用到 Agent Loop
这篇开始落到代码。我们不先搭框架也不先讨论复杂的任务规划而是从最朴素的一次模型调用开始给模型一段用户输入拿回一段回答。然后逐步加上工具定义、工具调用、工具结果回传和循环最后得到一个最小可运行的 Agent Loop。很多 Agent 框架看起来很神秘但拆到最小结构其实就是下面这条链路模型看到上下文 - 模型决定下一步 - Harness 执行动作 - 执行结果回到上下文 - 模型继续判断。这条链路一旦跑通后面再加更多工具、权限审批、上下文压缩、任务状态持久化本质上都是在扩展这个循环。第一步一次最普通的 LLM 调用最早的 ChatGPT 体验就是一问一答用户输入问题模型返回回答。我们先用 OpenAI 兼容接口写出这个最小过程。这里我使用的是本地部署的gpt-oss服务地址是http://localhost:8080/v1。本地部署大模型的方式可以参考前一篇《llama.cpp 入门在你的本地设备上运行大语言模型》。from openai import OpenAI client OpenAI( base_urlhttp://localhost:8080/v1, api_keyno-need, ) MODEL gpt-oss-20b-Q4_K_M.gguf def run_llm(message: str) - str: response client.chat.completions.create( modelMODEL, messages[ {role: system, content: You are a helpful assistant.}, {role: user, content: message}, ], ) return response.choices[0].message.content or if __name__ __main__: user_input input(Enter your message: ) output run_llm(user_input) print(Response from LLM:, output)一次普通调用的核心就是把messages交给模型再从choices[0].message里取回回答。这段代码很短但里面已经出现了几个 Agent 工程里绕不开的概念。messages 不是普通字符串很多人第一次接触 ChatCompletion 时会觉得messages只是把 prompt 换成了数组。这个理解只对了一半。messages更像一段结构化对话历史。每条消息都有role和content。content是具体内容role则表示这段内容是谁产生的以及它在对话里应该被如何理解。在本文这个最小 Agent 里先重点关注四种角色role生产者作用system系统或开发者给模型设定身份、目标和边界user用户表达任务、问题、约束或反馈assistant模型模型的自然语言回答或工具请求toolHarness工具执行后的结果你在 SDK 类型里还会看到developer、function等角色或兼容类型。它们各有历史和平台语义但对于理解最小 Agent Loop 来说先把上面四个角色想清楚就够了。这里有一个非常关键的点角色不是装饰信息而是上下文协议的一部分。同一句话如果放在system里通常代表更高优先级的行为约束放在user里代表用户本轮输入放在tool里则代表外部环境返回的事实。Agent 能不能稳定工作很大程度上取决于你有没有把信息放在正确的消息角色里。choices[0] 是什么上面的代码里有一行return response.choices[0].message.content or 为什么是choices[0]因为 ChatCompletion 的响应允许一次返回多个候选结果。你可以通过n参数要求模型生成多个候选回答。如果没有设置默认通常只有一个候选所以我们取choices[0]。普通问答里这个细节不太显眼。但到了 Agent 场景choices[0].message不一定只有自然语言内容它还可能包含tool_calls。也就是说模型下一步想做的事可能不是“回答用户”而是“请求 Harness 调用某个工具”。可以先记住这个判断assistant_message response.choices[0].message if assistant_message.tool_calls: # 模型希望调用工具 else: # 模型给出了最终回答Agent Loop 的分岔点基本就在这里。chat_template结构化消息如何进入模型对本地模型来说messages这种结构最终还是要变成模型能读懂的 token 序列。这个转换通常由chat_template完成。你可以把chat_template理解成一套序列化规则它规定system、user、assistant、tool这些消息如何被拼接工具定义如何被放进上下文模型输出工具调用时应该使用什么格式。在 OpenAI 兼容的本地服务里这个细节尤其重要。因为模型本身并不知道 Python 里的messages数组长什么样它看到的是被服务端模板化之后的文本或 token。如果模板和模型训练时使用的格式不匹配常见问题包括模型忽略system约束模型不会按预期生成工具调用模型把工具参数写成普通自然语言工具结果回传后模型无法理解那是外部执行结果。所以本地跑 Agent 时不只是“接口能调通”就结束了还要确认模型、服务端和chat_template的工具调用格式是一致的。第二步让模型知道有哪些工具一次普通调用只能产生文字。要让 Agent 真正做事就要让模型能够请求外部工具。先定义一个最简单的工具执行一条 shell 命令并返回输出。import os import subprocess def bash(command: str) - str: Execute a bash command and return the output. dangerous_keywords [rm, sudo, shutdown, reboot, init, poweroff] if any(keyword in command for keyword in dangerous_keywords): return Error: command contains dangerous keywords. try: result subprocess.run( command, shellTrue, cwdos.getcwd(), capture_outputTrue, textTrue, timeout120, ) output (result.stdout result.stderr).strip() return output[:5000] if output else 执行完成但没有输出。 except subprocess.TimeoutExpired: return Error: command execution timed out. except Exception as exc: return fError: an unexpected error occurred - {exc}这段代码只是教学用的最小示例。真实项目里不要只靠关键词过滤保护 shell 工具。关键词过滤很容易漏掉危险命令也很容易误伤正常命令。更好的做法是命令白名单、参数结构化、沙箱隔离、超时限制、权限审批和审计日志一起上。有了真实函数之后还要把它描述给模型。模型不能直接读取 Python 函数签名所以我们要通过tools参数告诉它有哪些工具、叫什么、做什么、需要哪些参数。TOOLS [ { type: function, function: { name: bash, description: Execute a shell command and return the output., parameters: { type: object, properties: { command: { type: string, description: The shell command to execute., } }, required: [command], }, }, } ]这里的TOOLS不是给 Python 解释器看的而是给模型看的。它告诉模型如果你需要执行命令可以生成一个名为bash的函数调用并提供一个command字符串。这个工具定义里也有几个细节值得注意name应该短、稳定、语义明确。后面做工具路由时Harness 会用它找到真实函数。description不是写给用户看的而是写给模型看的。它会影响模型什么时候选择这个工具。parameters本质上是一份 JSON Schema。它让模型知道参数结构也方便 Harness 做参数校验。required很重要。没有它模型可能会生成缺字段的调用请求。很多本地 OpenAI 兼容服务会把这份工具定义通过chat_template拼进模型上下文里。模型不是“天然知道”Python 里有个bash函数而是通过这份工具说明知道自己可以请求一个叫bash的外部动作。第三步模型并不会真的调用工具这是理解 tool calling 最重要的一点调用工具的不是大模型而是 Harness。模型只是在消息里生成“我想调用哪个工具以及参数是什么”。我们先写一个只执行一次工具调用的版本import json import os def run_tool_once(message: str) - str: response client.chat.completions.create( modelMODEL, messages[ { role: system, content: f你是一个命令行助手工作在当前目录{os.getcwd()}。, }, {role: user, content: message}, ], toolsTOOLS, ) assistant_message response.choices[0].message if not assistant_message.tool_calls: return assistant_message.content or tool_call assistant_message.tool_calls[0] if tool_call.function.name ! bash: return fError: unknown tool {tool_call.function.name} try: arguments json.loads(tool_call.function.arguments) except json.JSONDecodeError as exc: return fError: invalid tool arguments - {exc} command arguments.get(command) if not isinstance(command, str) or not command.strip(): return Error: missing command. tool_response bash(command) return f模型请求调用 bash({command!r})\n\n工具返回\n{tool_response}模型只生成工具调用意图Harness 负责解析、校验、执行再把结果放回对话。工具调用返回的大致结构可以这样理解tool_call.id # 这次工具调用的唯一 ID tool_call.type # 通常是 function tool_call.function.name # 模型想调用的工具名比如 bash tool_call.function.arguments # 模型生成的 JSON 字符串这里最容易踩坑的是arguments。它是字符串不是已经解析好的 dict。即便你给了parameters结构模型生成的参数也不应该被直接信任JSON 可能不合法字段可能缺失类型也可能不对。所以 Harness 必须先解析、再校验、最后才执行真实函数。这一步已经很接近 Agent 了模型可以选择工具Harness 可以执行工具。但它还有两个明显问题。第一工具调用是一次性的。调用完就结束模型没有机会继续判断下一步。第二模型并不知道工具执行结果。上面代码把tool_response直接返回给了用户但没有把它作为tool消息交还给模型。这样模型无法基于真实输出继续推理也无法把原始工具结果整理成用户真正想要的答案。所以我们还差一个循环。第四步把工具结果放回 messages一个最小 Agent Loop 的关键就是每次工具执行后把结果追加到messages里再让模型继续看。这时消息历史会变成这样user用户提出任务。assistant模型返回tool_calls表示想调用工具。toolHarness 执行工具把结果写回。assistant模型看到工具结果后继续回答或继续请求工具。Agent Loop 的本质是让工具结果成为下一轮推理的上下文而不是停留在 Harness 内部。代码可以这样写from typing import Any SYSTEM_PROMPT f 你是一个命令行助手工作在当前目录{os.getcwd()}。 你可以使用 bash 工具完成任务。 调用工具前先思考目标拿到工具结果后再判断是否需要继续。 def run_bash_tool(tool_call: Any) - str: try: arguments json.loads(tool_call.function.arguments) except json.JSONDecodeError as exc: return fError: invalid tool arguments - {exc} command arguments.get(command) if not isinstance(command, str) or not command.strip(): return Error: missing command. return bash(command) def run_loop(user_message: str, max_steps: int 8) - str: messages: list[dict[str, Any]] [{role: user, content: user_message}] for _ in range(max_steps): response client.chat.completions.create( modelMODEL, messages[{role: system, content: SYSTEM_PROMPT}] messages, toolsTOOLS, ) assistant_message response.choices[0].message if not assistant_message.tool_calls: final_answer assistant_message.content or messages.append({role: assistant, content: final_answer}) return final_answer messages.append( { role: assistant, content: assistant_message.content or , tool_calls: [ { id: tool_call.id, type: function, function: { name: tool_call.function.name, arguments: tool_call.function.arguments, }, } for tool_call in assistant_message.tool_calls ], } ) for tool_call in assistant_message.tool_calls: if tool_call.function.name bash: tool_result run_bash_tool(tool_call) else: tool_result fError: unknown tool {tool_call.function.name} messages.append( { role: tool, tool_call_id: tool_call.id, content: tool_result, } ) return 达到最大循环次数任务仍未完成。 if __name__ __main__: user_input input(Enter your message: ) output run_loop(user_input) print(Response from LLM:, output)这里有几个细节值得保留。第一工具结果要用role: tool写回messages。不要只在本地变量里保存结果也不要直接把工具输出打印给用户就结束。工具结果只有回到上下文里模型下一轮才能继续基于它推理。第二tool_call_id不能写错。当模型返回一个工具调用时这个调用会有自己的id。Harness 把工具结果写回messages时要用tool_call_id指向原来的调用。这样模型和服务端才能知道这条tool消息对应的是哪一次工具请求。第三带有tool_calls的assistant消息也要写回历史。也就是说不只是写入工具执行结果还要先保存模型提出的工具调用请求。否则消息链会断掉服务端或模型都可能无法正确理解后面的tool消息。第四要加max_steps这样的循环上限。Agent Loop 本质上是一个开放循环如果模型一直请求工具就可能无限跑下去。即便是教学代码也应该保留这个保险丝。如果上一轮有多个工具调用就要为每个工具调用追加一条对应的tool消息。这一点在下一篇讲多个工具时会更明显。这个循环为什么就是 Agent 的雏形现在再回头看这段run_loop它其实已经具备了一个 Agent 的基本骨架观察读取用户输入和历史消息。决策模型判断是直接回答还是请求工具。行动Harness 按工具名和参数执行真实函数。反馈工具结果以tool消息回到上下文。迭代模型基于新上下文继续决策直到输出最终答案。这就是最小的 Agent Loop。它还很简陋没有任务状态持久化没有权限审批没有丰富的工具系统也没有自动验证。但它已经把 Agent 最核心的结构跑通了模型不是孤立地产生文字而是在 Harness 提供的环境里反复观察、行动、接收反馈。从工程角度看Agent Loop 里真正重要的不是while或for语法而是每一轮都维护了这份状态messages [ {role: user, content: ...}, {role: assistant, tool_calls: [...]}, {role: tool, tool_call_id: ..., content: ...}, {role: assistant, content: ...}, ]只要这份消息栈是连续、可解释、可验证的模型就能在多轮工具调用中保持任务上下文。工程上还缺什么从教学示例走向可用 Agent中间还有不少工程问题要补。工具安全。像bash这种工具能力很强风险也很高。生产环境里必须有沙箱、白名单、审批、超时、资源限制和审计。参数校验。模型生成的 JSON 不一定合法字段也可能缺失或幻觉出来。Harness 必须验证参数不能把模型输出直接当成可信输入。上下文管理。循环次数多了之后messages会越来越长。需要压缩历史、保留关键事实、丢弃噪声并在必要时把长期状态持久化到外部存储。错误恢复。工具失败不是异常情况而是 Agent 日常工作的一部分。好的 Harness 应该让模型看懂错误并给它修复机会。完成判定。模型说“完成了”不一定真的完成。对代码任务可以跑测试对网页任务可以截图检查对数据任务可以校验输出格式。验证越明确Agent 越可靠。小结这篇我们从最简单的一次 LLM 调用出发逐步加上了三层能力用messages表达结构化对话历史用tools告诉模型有哪些外部动作可用用tool消息把执行结果放回上下文并形成循环。Agent 并不是某个神秘对象。至少在最小实现里它就是一个持续运行的消息循环模型负责判断下一步Harness 负责把下一步变成真实动作再把真实世界的反馈交还给模型。学AI大模型的正确顺序千万不要搞错了2026年AI风口已来各行各业的AI渗透肉眼可见超多公司要么转型做AI相关产品要么高薪挖AI技术人才机遇直接摆在眼前有往AI方向发展或者本身有后端编程基础的朋友直接冲AI大模型应用开发转岗超合适就算暂时不打算转岗了解大模型、RAG、Prompt、Agent这些热门概念能上手做简单项目也绝对是求职加分王给大家整理了超全最新的AI大模型应用开发学习清单和资料手把手帮你快速入门学习路线:✅大模型基础认知—大模型核心原理、发展历程、主流模型GPT、文心一言等特点解析✅核心技术模块—RAG检索增强生成、Prompt工程实战、Agent智能体开发逻辑✅开发基础能力—Python进阶、API接口调用、大模型开发框架LangChain等实操✅应用场景开发—智能问答系统、企业知识库、AIGC内容生成工具、行业定制化大模型应用✅项目落地流程—需求拆解、技术选型、模型调优、测试上线、运维迭代✅面试求职冲刺—岗位JD解析、简历AI项目包装、高频面试题汇总、模拟面经以上6大模块看似清晰好上手实则每个部分都有扎实的核心内容需要吃透我把大模型的学习全流程已经整理好了抓住AI时代风口轻松解锁职业新可能希望大家都能把握机遇实现薪资/职业跃迁这份完整版的大模型 AI 学习资料已经上传CSDN朋友们如果需要可以微信扫描下方CSDN官方认证二维码免费领取【保证100%免费】