豆包 Agent Harness 工程师入门 | 第 2 章

0 阅读12分钟

继续上一章的学习,上一章中我们只调用了一个工具 bash,但是现实中肯定是有很多工具要调用的,那么该如何组织这些工具的调用呢?

主 Agent 循环是不需要动的,我们只需要新建一个 工具目录,然后当 AI 需要调用不同的工具时,用代码来调用相应的工具即可。

S02 多工具调用

这一章我们设计四个工具来让 AI 调用,四个工具分别是 bash、read_file、write_file 和 edit_file,覆盖了大约 95% 的编程任务。

  • Bash 执行命令;
  • read_file 提供带行号的精确文件读取;
  • write_file 创建或覆盖文件;
  • edit_file 做精确的字符串替换。

工具越多,模型的认知负担越重——它必须在更多选项中做选择,选错的概率也随之增加。更少的工具也意味着更少的 schema 需要维护、更少的边界情况需要处理。

Ok,接下来让我们看看代码如何编写:

  1. 第一部分引入依赖
import json
import subprocess
import sys

from volcenginesdkarkruntime import Ark
from pathlib import Path
from config import api_key

这一部分 没啥好说的。

  1. 第二部分定义全局变量
# 为防止泄露,我把 api key 放在 config.py 中了,当然很多教程都是放在环境变量中的,都行吧。
client = Ark(api_key=api_key)
# 配置沙盒工作路径
WORKDIR = Path.cwd()
# 模型名称
MODEL = "doubao-seed-2-0-code-preview-260215"
# 系统顶级指令
SYSTEM = f"你是一个位于 {WORKDIR} 工作目录的编程智能体。请使用 Bash 来解决任务。只管行动,不要解释。只管行动,不要解释。"

这里跟第一章一样,这里设置了一下沙盒工作目录,然后后面用代码判断, AI 要操作的文件是否在这个目录里,用于保障安全。

# 工具索引
TOOL_HANDLERS = {
    "run_bash": lambda **kw: run_bash(kw["command"]),
    "read_file": lambda **kw: run_read(kw["path"], kw.get("limit")),  # limit 不是必须参数,因此要做容错处理。
    "write_file": lambda **kw: run_write(kw["path"], kw["content"]),
    "edit_file": lambda **kw: run_edit(kw["path"], kw["old_text"], kw["new_text"]),
}

然后建立一个工具索引,这样用 lambda 表达式将工具的名称和后面要写的的 python 函数联系起来,然后就可以使用代码 TOOL_HANDLERS.get(func_name)(**func_args) 来调用了。就可以不用编写一大串的 if else 来调用了,哈哈哈。

# 工具定义
TOOLS = [
    {
        "type": "function",
        "function": {
            "name": "run_bash",
            "description": "执行 shell 命令",
            "parameters": {
                "type": "object",
                "properties": {
                    "command": {
                        "type": "string",
                        "description": "要执行的 shell 命令"
                    }
                },
                "required": ["command"]
            }
        },
    },
    {
        "type": "function",
        "function": {
            "name": "read_file",
            "description": "读取文件内容",
            "parameters": {
                "type": "object",
                "properties": {
                    "path": {
                        "type": "string",
                        "description": "文件路径"
                    },
                    "limit": {
                        "type": "integer",
                        "description": "读取的行数"
                    }
                },
                "required": ["path"]
            }
        }
    },
    {
        "type": "function",
        "function": {
            "name": "write_file",
            "description": "将内容写入文件",
            "parameters": {
                "type": "object",
                "properties": {
                    "path": {
                        "type": "string",
                        "description": "文件路径"
                    },
                    "content": {
                        "type": "string",
                        "description": "需要向文件中写入的内容"
                    }
                },
                "required": ["path", "content"]
            }
        }
    },
    {
        "type": "function",
        "function": {
            "name": "edit_file",
            "description": "替换完全匹配的文字, 只替换那些完全一模一样的文字,不会自作聪明地去替换相似的内容, 且只替换一次",
            "parameters": {
                "type": "object",
                "properties": {
                    "path": {
                        "type": "string",
                        "description": "文件路径"
                    },
                    "old_text": {
                        "type": "string",
                        "description": "需要被替换的旧文字"
                    },
                    "new_text": {
                        "type": "string",
                        "description": "新文字"
                    },
                },
                "required": ["333"]
            }
        }
    }

]

然后是工具的定义,这里是变化最大的地方,但是其实都是格式方面的变化,内容方面其实变化很小很小,大部分内容都是必须写的 json 格式字段,我感觉以后肯定是要优化工具的编写方式的,直接使用 yaml 的格式多好,还能省 token,还方便人类和 AI 阅读。

def safe_path(p: str) -> Path:
    path = (WORKDIR / p).resolve()
    if not path.is_relative_to(WORKDIR):
        raise ValueError(f"【错误】:路径逃逸 > {p}")
    return path

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=WORKDIR, 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: 命令执行失败,用户取消执行"

def run_read(path: str, limit: int = None) -> str:
    try:
        text = safe_path(path).read_text()  # 检测文件路径是否安全
        lines = text.splitlines()  # 读文件内容
        if limit and limit < len(lines):  # 如果设定了读取的行数
            lines = lines[:limit] + [f"... ({len(lines) - limit} more lines)"]
        return "\n".join(lines)[:50000]
    except Exception as e:
        return f"Error:{e}"

def run_write(path: str, content: str) -> str:
    try:
        fp = safe_path(path)
        fp.parent.mkdir(parents=True, exist_ok=True)  # “如果父目录不存在,也一并创建”, “如果文件夹已经存在了,不要报错,直接跳过”。
        fp.write_text(content)
        return f"OK, 已向 {path} 写入了 {len(content)} 字节"
    except Exception as e:
        return f"Error:{e}"

def run_edit(path: str, old_text: str, new_text: str) -> str:
    try:
        fp = safe_path(path)
        content = fp.read_text()
        if old_text not in content:
            return f"Error: 文件中未找到旧文本"
        fp.write_text(content.replace(old_text, new_text, 1))  # 参数 1,它表示只替换第一次出现的位置
        return f"{path} 文件已修改"
    except Exception as e:
        return f"Error: {e}"

然后是四个函数对应的 python 代码。这里编写了一个 safe_path 函数来判断 AI 是否将文件放到沙盒外面了。

然后四个函数的逻辑也很简单,看看注释也就明白是什么意思了。

# 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: \033[0m\033[36;9m思考: {message.content}\033[0m\n\033[36m回答:{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. 路由并执行本地函数
                handler = TOOL_HANDLERS.get(func_name)
                output = handler(**func_args) if handler else f"不存在 {func_name} 工具"
                print(f"\n\033[33mtools : {output}\033[0m\n")
                messages.append({"role": "tool", "content": output, "tool_call_id": call_id})

接下来是核心循环,基本没啥变化,就这里变了一点

# 3. 路由并执行本地函数
handler = TOOL_HANDLERS.get(func_name)
output = handler(**func_args) if handler else f"不存在 {func_name} 工具"

也很好理解,就是利用前面 TOOL_HANDLERS 里写的 lambda 函数来直接调用 AI 想调用的 tool 即可。

# 主程序入口
if __name__ == "__main__":
    # 注入模型需遵循的指令,包括扮演的角色、背景信息等。
    history = [{"role": "system", "content": SYSTEM}]
    try:
        hi = f"{ Path(sys.argv[0]).stem} :) 你好人类👋 (输入 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))

然后是主程序入口,也没啥变化。

然后我让 AI 帮我写了首诗,看看 AI 写的咋样:

s02 :) 你好人类👋 (输入 exit 退出)
>> 帮我写一首七言律诗,诗名为"牛马", 诗词大意为现代打工人工作的艰辛。写入到 牛马.md 文件中

AI: 让我来帮你完成这个任务。首先我需要确认当前的工作目录,然后创建一个包含七言律诗的文件。

让我先查看当前目录结构。

系统检测到调用 run_bash 函数, 需要执行的命令为 >>> pwd <<< 是否允许 
 Y/N >>> Y

tools : /Users/sanqiushu/Code_Space/PyCharm_Space/Project/test_ai_agent/S02


AI: 好的,现在我确认我在正确的工作目录下。接下来,我需要写一首关于现代打工人艰辛的七言律诗,诗名为"牛马",然后将其写入到牛马.md文件中。

让我先构思一下这首诗:
- 诗名:牛马
- 主题:现代打工人工作的艰辛
- 体裁:七言律诗

我会按照要求直接写好内容并写入文件。


tools : OK, 已向 /Users/sanqiushu/Code_Space/PyCharm_Space/Project/test_ai_agent/S02/牛马.md 写入了 74 字节


AI: 好的,我已经成功写了一首关于现代打工人艰辛的七言律诗,并将其写入到牛马.md文件中。让我验证一下文件是否创建成功,并查看其内容。


tools : # 牛马

朝九晚五已成奢,熬夜加班是日常。
地铁拥挤人如蚁,工位狭小坐似僵。
房贷压肩难喘息,账单追着跑断肠。
莫道人生无再少,只因肩上有担当。


AI: 很好!文件已成功创建,我的任务已完成。这首七言律诗"牛马"描绘了现代打工人的艰辛生活,从工作时间、通勤环境、经济压力等方面展现了当代职场人的不易。

然后我们来看看网络层面 AI 和代码的交互过程吧。

{
    "role": "system",
    "content": "你是一个位于 /Users/sanqiushu/Code_Space/PyCharm_Space/Project/test_ai_agent/S02 工作目录的编程智能体。请使用 Bash 来解决任务。只管行动,不要解释。只管行动,不要解释。"
},

第一句是系统提示词

{
    "role": "user",
    "content": "帮我写一首七言律诗,诗名为"牛马", 诗词大意为现代打工人工作的艰辛。写入到 牛马.md 文件中"
},

第二句是我发给 AI 的命令

{
    "content": "我会帮你写一首关于现代打工人艰辛的七言律诗,并将其写入到牛马.md文件中。让我先确认一下当前的工作目录。",
    "role": "assistant",
    "function_call": null,
    "tool_calls": [
        {
            "id": "call_314llly3bu4aza8229lot46g",
            "function": {
                "arguments": "{"command": "pwd"}",
                "name": "run_bash"
            },
            "type": "function"
        }
    ],
    "audio": null,
    "reasoning_content": "让我来帮你完成这个任务。首先我需要确认当前的工作目录,然后创建一个包含七言律诗的文件。\n\n让我先查看当前目录结构。"
},

第三句是 AI 的响应,AI 说它会帮我完成任务,但是先要查看工作目录,然后 AI 就调用了 run_bash 函数,参数是 pwd

{
    "role": "tool",
    "content": "/Users/sanqiushu/Code_Space/PyCharm_Space/Project/test_ai_agent/S02",
    "tool_call_id": "call_314llly3bu4aza8229lot46g"
},

然后是代码将 run_bash tool 的执行结果返回给了 AI

{
    "content": "好的,现在我来写一首关于现代打工人艰辛的七言律诗,并将其写入到牛马.md文件中。\n",
    "role": "assistant",
    "function_call": null,
    "tool_calls": [
        {
            "id": "call_iv2yfz2qrfau04vi12p5gznc",
            "function": {
                "arguments": "{"path": "/Users/sanqiushu/Code_Space/PyCharm_Space/Project/test_ai_agent/S02/牛马.md", "content": "# 牛马\n\n朝九晚五已成奢,熬夜加班是日常\n地铁拥挤人如蚁,工位狭小坐似僵\n房贷压肩难喘息,账单追着跑断肠\n莫道人生无再少,只因肩上有担当\n"}",
                "name": "write_file"
            },
            "type": "function"
        }
    ],
    "audio": null,
    "reasoning_content": "好的,现在我确认我在正确的工作目录下。接下来,我需要写一首关于现代打工人艰辛的七言律诗,诗名为"牛马",然后将其写入到牛马.md文件中。\n\n让我先构思一下这首诗:\n- 诗名:牛马\n- 主题:现代打工人工作的艰辛\n- 体裁:七言律诗\n\n我会按照要求直接写好内容并写入文件。"
},

AI 拿到 tool 执行后的结果就生成了一首诗,并且调用了 write_file 工具,将这首诗写入到了 牛马.md 文件中

{
    "role": "tool",
    "content": "OK, 已向 /Users/sanqiushu/Code_Space/PyCharm_Space/Project/test_ai_agent/S02/牛马.md 写入了 74 字节",
    "tool_call_id": "call_iv2yfz2qrfau04vi12p5gznc"
},

然后 write_file tool 工具将执行结果返回给了 AI

{
    "content": "任务已完成!我已经为你写了一首关于现代打工人艰辛的七言律诗,诗名为"牛马",并将其写入到了牛马.md文件中。\n\n让我验证一下文件是否创建成功:\n",
    "role": "assistant",
    "function_call": null,
    "tool_calls": [
        {
            "id": "call_cm9coquwqgr49ky5eeslsklm",
            "function": {
                "arguments": "{"path": "/Users/sanqiushu/Code_Space/PyCharm_Space/Project/test_ai_agent/S02/牛马.md"}",
                "name": "read_file"
            },
            "type": "function"
        }
    ],
    "audio": null,
    "reasoning_content": "好的,我已经成功写了一首关于现代打工人艰辛的七言律诗,并将其写入到牛马.md文件中。让我验证一下文件是否创建成功,并查看其内容。"
},

然后 AI 调用 read_file tool 读取了一下文件

{
    "role": "tool",
    "content": "# 牛马\n\n朝九晚五已成奢,熬夜加班是日常。\n地铁拥挤人如蚁,工位狭小坐似僵。\n房贷压肩难喘息,账单追着跑断肠。\n莫道人生无再少,只因肩上有担当。",
    "tool_call_id": "call_cm9coquwqgr49ky5eeslsklm"
},

read_file tool 将执行结果返回给了 AI

{
   "content": "很好!文件已成功创建,我的任务已完成。这首七言律诗"牛马"描绘了现代打工人的艰辛生活,从工作时间、通勤环境、经济压力等方面展现了当代职场人的不易。",
   "role": "assistant",
   "function_call": null,
   "tool_calls": null,
   "audio": null,
   "reasoning_content": "完美!文件已经成功创建,并且包含了我写的七言律诗。这首诗描绘了现代打工人的艰辛生活:\n\n- 首联写朝九晚五的工作已经变得奢侈,熬夜加班成为日常\n- 颔联写通勤时地铁拥挤,工作环境狭小\n- 颈联写房贷和生活账单带来的经济压力\n- 尾联表达了打工人的责任感\n\n任务已经完成,文件已经成功创建在指定的工作目录中。"
}

然后 AI 认为完美完成任务啦。 但是我感觉这个诗的不咋滴啊,然后我又让 AI 帮我重新写了一个,这下有味道了,很好,我很满意。

《牛马》
晨曦未露已出门,暮色苍茫始返村。
案牍劳形神欲碎,加班加点骨将存。
房租水电催交急,米面油盐算计频。
长叹此生如牛马,何时得见自由身?

image.png