Agent Harness 工程师入门 | 第 1 章

6 阅读10分钟

2023 年 6 月 OpenAI 研究与安全副总裁翁荔(Lilian Weng) 发表了一篇文章:《LLM-powered Autonomous Agents》可以翻译为 《由大语言模型驱动的自主智能体》这篇文章是 AI Agent 领域的奠基之作。它系统性地拆解了如何让大语言模型(LLM)从“只会说话的聊天机器人”进化为“能独立解决复杂问题的智能体”。

这篇文章提出的 Agent 基本架构如下所示:

image.png

然后今天在 Github 上看到一个教程,正好可以用来学习一下如何编写 Agent。不过这个教程是英文的,实在是不方便我复习,所以还是在这里自己记录一下学习的过程,以备复习吧。 https://github.com/shareAI-lab/learn-claude-code/blob/main/README-zh.md

像 2026年3月 大火的小龙虾🦞,还有 Claude Code 、Codex 、Trae 等等都是一个 Agent,在本质上跟本文写的 Agent 是一个东西,只是功能上复杂一点而已,学会了原理,你也可以写出一个很好用的 Agent。

S01 【The Agent Loop】 Agent 循环

最简单的 Agent 代码其实就是写一个 while 循环。当 AI 大语言模型需要现实世界的信息或操作现实世界的时候,让 Agent 去访问现实世界即可。

简单表达的话就是下面这种结构:

image.png

大语言模型可以推理代码,但无法触及现实世界——无法读取文件、运行测试或检查错误。如果没有循环,每次工具调用都需要你手动复制粘贴结果,你就成为了循环的一部分。所以我们写 Agent Harness 就是替换你在这个循环的中的作用。

由此可见,我们可以设计一个基本的逻辑,大语言模型可以多次调用工具,直到大语言模型认为任务完成了,才结束整个流程。

然后,我们来用 Python 代码实现一下上面的逻辑。不过在那之前,先去开通一下大模型的 API。开通那个平台倒是无所谓,用你最喜欢的就可以了,我直接注册了字节的火山引擎.... 不过还好,跟广告说的一样,送 50万 token,行吧先用着吧。

image.png

访问火山引擎,然后登陆,然后开通 API 获取 API key 即可。 image.png 然后调用一下 API 教程提供的测试代码,看看 API key 开通成功了没有。

先下载 openai SDK: pip install --upgrade "openai>=1.0"

import os
from openai import OpenAI
from config import api_key

# 请确保您已将 API Key 存储在环境变量 ARK_API_KEY 中
# 初始化Ark客户端,从环境变量中读取您的API Key
client = OpenAI(
    # 此为默认路径,您可根据业务所在地域进行配置
    base_url="https://ark.cn-beijing.volces.com/api/v3",
    # 为防止泄露,我把 api key 放在 config.py 中了,当然很多教程都是放在环境变量中的,都行吧。
    api_key=api_key,
)

response = client.chat.completions.create(
    # 指定您创建的方舟推理接入点 ID,此处已帮您修改为您的推理接入点 ID
    model="doubao-seed-2-0-code-preview-260215",
    messages=[
        {
            "role": "user",
            "content": [
                {
                    "type": "image_url",
                    "image_url": {
                        "url": "https://ark-project.tos-cn-beijing.ivolces.com/images/view.jpeg"
                    },
                },
                {"type": "text", "text": "这是哪里?你是谁?"},
            ],
        }
    ],
)

print(response.choices[0])

这是模型返回的信息, 真能 BB ,浪费我 Token。

Choice(finish_reason='stop', index=0, logprobs=None, message=ChatCompletionMessage(content='这里可能是一处郊外的湖泊,周围有山脉和森林。我是豆包,是字节跳动开发的 AI,能帮你解答各种问题、聊天互动、提供信息等。', refusal=None, role='assistant', annotations=None, audio=None, function_call=None, tool_calls=None, reasoning_content='用户现在需要解决的问题是:首先看图片,是一个人在湖泊皮划艇,背景雪山森林。然后问题是“这是哪里?你是谁?”。首先,我作为AI,要说明我是豆包,是字节跳动的AI助手。然后关于“这是哪里”,因为图片是艺术创作或示意图,没有明确地标,所以说明无法确定具体地点,只能描述场景:一个有雪山、森林的湖泊,有人皮划艇。\n\n现在组织语言:\n\n首先,我是豆包,是字节跳动开发的AI助手,帮用户解答问题、聊天等。\n\n然后关于地点:这张图片展示的是一个自然风光场景,有宁静的湖泊、茂密的森林、远处的雪山,还有人在皮划艇,但因为没有具体地标(比如独特的建筑、地名标识),所以无法确定这是现实世界中的哪个具体地点,可能是艺术家创作的想象场景,或是类似北欧、阿拉斯加、加拿大这类有雪山湖泊森林的地区的概括性描绘。\n\n现在检查语言是否和问题一致(用户用中文,所以回答中文),结构清晰:先介绍自己,再分析地点。\n\n现在整理成自然的中文回答:\n\n我是豆包,是字节跳动打造的人工智能助手,能为你提供信息查询、聊天互动等帮助~  \n\n至于“这是哪里”,从图片来看,它呈现的是一处自然风光:平静的湖泊上有人划着皮划艇,周围是茂密的森林,远处矗立着雪山。但因为图片没有明确的地理标识(比如专属地名、独特建筑),所以无法确定这是现实中**具体哪个地点**——它可能是艺术家创作的想象场景,也可能是对北欧、阿拉斯加、加拿大落基山脉等“有雪山、森林、湖泊”的地区的概括性描绘哦~'))

然后让我们正式改造这段代码,让它变成一个 Agent 循环。

import json
import os
import subprocess

from volcenginesdkarkruntime import Ark
from config import api_key

首先引入一些包,config 包是我放 api_key 的 .py 文件。然后这个 volcenginesdkarkruntime 巨抽象的包名是字节火山引擎的包名,需要提前使用 pip install 'volcengine-python-sdk[ark]' 进行下载。

然后编写一些全局变量

# 为防止泄露,我把 api key 放在 config.py 中了,当然很多教程都是放在环境变量中的,都行吧。
client = Ark( api_key = api_key )

# 模型名称
MODEL = "doubao-seed-2-0-code-preview-260215"
# 系统顶级指令
SYSTEM = f"你是一个位于 {os.getcwd()} 工作目录的编程智能体。请使用 Bash 来解决任务。只管行动,不要解释。只管行动,不要解释。"
# 工具列表
TOOLS = [{
    "type": "function",
    "function": {
        "name": "run_bash",
        "description": "执行 shell 命令",
        "parameters": {
            "type": "object",
            "properties": {
                "command": {
                    "type": "string",
                    "description": "要执行的 shell 命令"
                }
            },
            "required": ["command"]
        }
    },
}]

因为用的是火山引擎的 SDK ,直接连 baseurl 都不用写了,Ark 类自己知道自家的 baseurl 是啥。

这个工具列表是按照火山引擎的 Chat API 教程编写的,跟前面介绍的教程里的 Anthropic 家的 API 不太一样。

def run_bash(command: str) -> str:
    dangerous = ["rm -rf /", "sudo", "shutdown", "reboot", "> /dev/"]
    if any(d in command for d in dangerous):
        return "Error: 危险命令,已被拦截"
    # 在自己电脑上搞这种危险的操作,我还是挺怕怕的,还是加个人工操作吧。😂
    tmp = input(f"系统检测到调用 run_bash 函数, 需要执行的命令为 >>> \033[31m{command}\033[0m <<< 是否允许 \n Y/N >>> ")
    if tmp == "Y" or tmp == "y":
        try:
            r = subprocess.run(command, shell=True, cwd=os.getcwd(), capture_output=True, text=True, timeout=120)
            out = (r.stdout + r.stderr).strip()
            return out[:50000] if out else "(执行完毕,但无输出)"
        except subprocess.TimeoutExpired:
            return "Error: 命令执行失败,120 秒超时"
    else:
        return "Error: 命令执行失败,用户取消执行"

然后定义一个执行 bash 命令的函数。

# Agent 循环的核心方法
def agent_loop(messages: list):
    while True:
        response = client.chat.completions.create( model = MODEL, messages = messages, tools = TOOLS, max_tokens = 8000 )
        # 将大模型返回的消息添加到消息列表中
        message = response.choices[0].message
        messages.append(message.model_dump())
        # 如果大模型没有调用工具,那么结束执行
        if response.choices[0].finish_reason != "tool_calls":
            # 打印 AI 的响应
            print(f"\n\033[36mAI: {message.content}\033[0m\n")
            return
        # 打印 AI 的响应
        print(f"\n\033[36mAI: {message.reasoning_content}\033[0m\n")
        if message.tool_calls:
            for tool_call in message.tool_calls:
                func_name = tool_call.function.name
                func_args = json.loads(tool_call.function.arguments)
                call_id = tool_call.id
                # 3. 路由并执行本地函数
                if func_name == "run_bash":
                    result = run_bash(command=func_args.get("command"))
                    print(f"\n\033[33mtools : {result}\033[0m\n")
                else:
                    result = "Error: Function not found"
                messages.append({"role": "tool", "content": result, "tool_call_id":call_id})

然后这里就是 Agent 核心循环了,当 AI 响应的 finish_reason 不是 tool_calls 的时候,直接结束循环,如果 AI 响应的 finish_reasontool_calls 那么执行工具调用的逻辑。

这一句 messages.append(message.model_dump()) 中,message 是一个对象,不能直接加入到 list 中去,必须先转成 dict 才行。

这一句 message = response.choices[0].message 中的 choices[0] 是因为大模型在生成回复时,本质上是在做概率预测。所以,API 返回的 JSON 里会有一个 choices 列表,里面装着模型给出的所有“选项”,但是如果请求里没有特殊设置,这个 choices 列表就只有一个概率最大的选项而已。

顺便来看一下 AI 响应的数据响应体是什么样子:

{
  "id": "02177485752710481a9193ce9f89e3456d252c7b9d01d0342be7e",
  "choices": [
    {
      "finish_reason": "tool_calls",
      "index": 0,
      "logprobs": null,
      "message": {
        "content": "",
        "role": "assistant",
        "tool_calls": [
          {
            "id": "call_vi59nfph9j1ak81vexpl05hi",
            "function": {
              "arguments": "{\"command\": \"echo 'print(\\\"Hello, World!\\\")' > hello.py\"}",
              "name": "run_bash"
            },
            "type": "function"
          }
        ],
        "reasoning_content": "用户让我创建一个叫hello.py的文件,打印\"Hello, World!\"。首先我需要确认当前目录,根据系统提示,我在/Users/sanqiushu/Code_Space/PyCharm_Space/Project/test_ai_agent/S01这个目录下。那我直接用echo命令或者cat来创建文件吧。比如用echo 'print(\"Hello, World!\")' > hello.py,这样应该可以。然后可能需要确认文件是否创建成功,不过用户没说,先创建再说。那我现在执行这个bash命令。"
      }
    }
  ],
  "created": 1774857543,
  "model": "doubao-seed-2-0-code-preview-260215",
  "service_tier": "default",
  "object": "chat.completion",
  "usage": {
    "completion_tokens": 167,
    "prompt_tokens": 462,
    "total_tokens": 629,
    "prompt_tokens_details": {
      "cached_tokens": 0
    },
    "completion_tokens_details": {
      "reasoning_tokens": 122
    }
  }
}

然后是 main 函数:

# 主程序入口
if __name__ == "__main__":
    # 注入模型需遵循的指令,包括扮演的角色、背景信息等。
    history = [{"role": "system", "content": SYSTEM}]
    try:
        hi = "s01 :) 你好人类👋 (输入 exit 退出)"
        query = input(f"\n\033[36m{hi}\033[0m\n>> ")
    except (EOFError, KeyboardInterrupt):
        exit()
    if query.strip().lower() in ("q", "exit", ""):
        exit()
    history.append({"role": "user", "content": query})
    agent_loop(history)
    print(json.dumps(history, indent=4, ensure_ascii=False))
    open("result.json", "w").write(json.dumps(history, indent=4, ensure_ascii=False))

然后展示一下这个 Agent 的运行过程吧:

image.png

image.png

image.png

执行完成。当然,你也可以在 main 函数里加 while 循环,以达到完成一个任务后,继续执行下一个对话,或者新建一个对话的功能。

总体来说,这个代码还是挺简单的,区区 100 行代码就完成了一个 Agent 最核心的功能,就是记录与 AI 大模型的历史对话、记录本地提供的工具目录,然后按照官方提供的数据格式,提交给 AI 大模型,AI 大模型就会懂得如何调用工具,说真的,有一种很神奇的感觉,像是在调用云端的脑力。