AI Agent框架探秘:拆解 OpenHands(12)--- Function call

AI Agent框架探秘:拆解 OpenHands(12)--- Function call

目录

  • [AI Agent框架探秘:拆解 OpenHands(12)--- Function call](#AI Agent框架探秘:拆解 OpenHands(12)--- Function call)
    • [0x00 概要](#0x00 概要)
    • [0x01 工具系统设计](#0x01 工具系统设计)
      • [1.1 需求](#1.1 需求)
      • [1.2 工具调用的本质](#1.2 工具调用的本质)
      • [1.3 设计原则](#1.3 设计原则)
        • [1.3.1 工具抽象与标准化原则](#1.3.1 工具抽象与标准化原则)
        • [1.3.2 工具与 LLM 解耦原则](#1.3.2 工具与 LLM 解耦原则)
        • [1.3.3 LLM 自主决策核心原则](#1.3.3 LLM 自主决策核心原则)
        • [1.3.4 结构化交互原则](#1.3.4 结构化交互原则)
        • [1.3.5 结果闭环与迭代推理原则](#1.3.5 结果闭环与迭代推理原则)
        • [1.3.6 广义工具拓展原则](#1.3.6 广义工具拓展原则)
        • [1.3.7 分层调用或者渐进式原则](#1.3.7 分层调用或者渐进式原则)
      • [1.4 Anthropic 设计高效工具的最佳实践](#1.4 Anthropic 设计高效工具的最佳实践)
      • [1.5 Agent 工具调用的生命周期](#1.5 Agent 工具调用的生命周期)
    • [0x02 OpenHands 的设计](#0x02 OpenHands 的设计)
      • [2.1 工具调用引擎](#2.1 工具调用引擎)
      • [2.2 核心设计模式](#2.2 核心设计模式)
      • [2.3 鲁棒性与兼容性解决方案](#2.3 鲁棒性与兼容性解决方案)
    • [0x03 功能解析](#0x03 功能解析)
      • [3.1 流程](#3.1 流程)
      • [3.2 工具注册与管理](#3.2 工具注册与管理)
        • [3.2.1 如何与 LLM 确定 tool_calls](#3.2.1 如何与 LLM 确定 tool_calls)
        • [3.2.2 配置工具列表](#3.2.2 配置工具列表)
        • [3.2.3 支持的工具类型](#3.2.3 支持的工具类型)
      • [3.3 BrowserTool](#3.3 BrowserTool)
      • [3.4 Python 解释器集成](#3.4 Python 解释器集成)
      • [3.5 解析工具调用](#3.5 解析工具调用)
        • [3.5.1 在 CodeActAgent 的使用](#3.5.1 在 CodeActAgent 的使用)
        • [3.5.2 构造tools相关信息](#3.5.2 构造tools相关信息)
        • [3.5.3 解析工具调用 LLM响应解析与转换](#3.5.3 解析工具调用 LLM响应解析与转换)
    • [0xFF 参考](#0xFF 参考)

0x00 概要

"会说话的只是ChatBot,会调工具做事的才叫Agent"。

大模型本质上是一个文本生成器,它不能直接操作系统、调用 API、访问数据库。所有这些能力都需要额外的工程实现。Agent 工具使用模式是突破大语言模型(LLM)固有局限、实现 Agent 与现实世界交互的核心架构范式,其本质是让 LLM 从单纯的文本生成器转变为具备感知、推理和行动能力的智能体,核心依托 ReAct 循环中模型对工具调用时机的自主决策能力。

function_calling.py 文件是 OpenHands 中 CodeActAgent 的核心组件,负责将 LLM 的函数调用响应转换为具体的 Agent Action,填补意图与执行的 "翻译鸿沟"。

因为本系列借鉴的文章过多,可能在参考文献中有遗漏的文章,如果有,还请大家指出。

0x01 工具系统设计

我们首先看看工具系统的模式、鲁棒性与引擎架构。

1.1 需求

LLM 本身受限于静态训练数据,无法获取实时信息、执行外部操作或访问专有数据,而工具使用模式通过搭建 LLM 与外部系统的桥梁,解决了这一关键问题。该模式的核心逻辑是将外部能力封装为 "工具",让 LLM 基于用户需求自主决策工具的调用策略,再通过框架层完成工具执行与结果反馈,最终由 LLM 整合结果形成响应或推进下一步流程。

相较于狭义的 "函数调用","工具调用" 的概念更具实践价值,其涵盖的工具类型不仅包括基础函数,还拓展至复杂 API、数据库交互及跨 Agent 指令传递等,这使得 Agent 能够作为数字资源与智能实体的编排者,实现更复杂的任务协作。在开发实践中,开发者只需通过工具注册表声明式注册原子化工具,无需编写业务流程代码,工具的组合逻辑由 LLM 在运行时动态生成,再由框架的调度模块完成执行,这种设计充分释放了 LLM 的推理决策能力,让 Agent 具备了动态适配复杂任务的灵活性。

工具使用模式的适用场景也有明确的经验法则:当 Agent 需突破 LLM 内部知识边界,开展实时数据获取、私有信息查询、精确计算、外部系统操作等任务时,该模式成为构建具备环境感知与交互能力的强 Agent 的基础选择。

1.2 工具调用的本质

工具调用的核心在于:LLM需要把用户的非结构化需求(一段自然语言文本)转换为结构化的函数调用(函数名和参数),然后与其他应用程序交互,再将结构化结果返回给模型,让模型能够基于这些结果进行下一步决策。

问题的本质在于,历史上其他系统(数据库、API、文件系统等)只能处理结构化信息,而LLM擅长处理非结构化信息(文本)。因此,LLM必须想办法在两种信息形式之间架起桥梁:将非结构化的用户需求转换为结构化的函数调用,这样才能与外部系统交互。

工具调用解决了核心问题:让LLM能够稳定地输出结构化的工具调用请求,实现了"非结构化→结构化"的转换。这是AI Agent工具能力的基础。

1.3 设计原则

Function Call 的目标不是让模型"会调用工具",而是让它"根据业务逻辑正确调用工具"。难点不在工具本身,而在"决策",模型到底什么时候调用、调用哪个、调用顺序是什么、缺信息时要不要追问、多轮对话怎么推进。 这需要进行针对性训练,也需要在实际使用中做针对性调整。

因此,Agent 工具使用模式的核心设计原则围绕 "解耦、智能决策、扩展性、实用性" 四大核心展开,是保障该模式能高效落地、适配复杂场景的关键准则,具体可梳理为以下几大原则:

合格的Agent Tool 应该是一个**"可理解、安全且具备容错能力"**的交互接口。

1.3.1 工具抽象与标准化原则

工具需被抽象为统一的接口范式,无论其底层是函数、API、数据库查询还是其他 Agent,都需定义标准化的描述维度(如名称、用途、参数类型与约束、返回值格式)。这种标准化让 LLM 能以一致的逻辑理解和调用不同类型的工具,也让框架的编排层能统一处理工具的执行请求,避免因工具类型差异导致的调用逻辑混乱。例如,将 "天气查询 API" 和 "数据分析 Agent" 都封装为包含 "入参 - 出参 - 功能描述" 的工具对象,让 LLM 无需区分其底层实现即可决策调用。

1.3.2 工具与 LLM 解耦原则

通过 工具注册表(ToolRegistry) 实现工具与 LLM 的解耦,工具的注册、更新、移除独立于 LLM 的推理逻辑。框架在启动时完成工具的实例化与注册,LLM 仅通过注册表获取工具的 "声明信息",调用时也由调度层通过注册表查找并执行工具。这种设计让工具的迭代无需修改 LLM 的推理逻辑,同时支持动态扩展工具集,例如新增 "邮件发送工具" 时,仅需在注册表中完成注册,LLM 即可感知并使用该工具。

1.3.3 LLM 自主决策核心原则

将工具组合与调用的决策权完全交予 LLM,开发者仅负责提供原子化工具,不编写固定的业务流程代码。LLM 基于用户请求的复杂程度、工具的能力边界,在运行时动态生成工具调用的顺序、参数与次数,实现 "按需组合工具"。这一原则充分发挥了 LLM 的推理能力,让 Agent 能适配未预设的复杂任务场景,例如用户要求 "分析近一周的股票数据并生成可视化报告",LLM 可自主决策先调用 "股票数据查询工具",再调用 "数据分析工具",最后调用 "可视化生成工具"。

1.3.4 结构化交互原则

LLM 与框架之间的工具调用交互需遵循结构化数据格式(如 JSON) ,而非自然语言。LLM 生成的工具调用请求需明确包含 "工具名称、参数键值对、调用优先级" 等结构化信息,框架的编排层通过解析该结构化数据执行工具,避免因自然语言歧义导致的调用错误。这一原则是保障工具调用准确性的基础,例如 LLM 生成{"tool_name": "weather_query", "params": {"city": "北京", "date": "2025-12-01"}}的结构化请求,框架可直接解析并执行对应的天气查询逻辑。

1.3.5 结果闭环与迭代推理原则

工具执行的结果需完整回传给 LLM,形成 "请求 - 决策 - 调用 - 反馈 - 再决策" 的闭环推理流程。LLM 结合工具反馈的结果,可进一步判断是否需要继续调用其他工具、调整参数重新调用同一工具,或整合结果生成最终响应。这一原则让 Agent 具备 "反思式" 的推理能力,例如调用 "翻译工具" 得到的结果不符合需求时,LLM 可自主决策调整翻译的目标语言参数,重新调用工具获取更准确的结果。

1.3.6 广义工具拓展原则

突破 "工具 = 函数" 的狭义认知,将工具的范畴拓展至API、数据库、其他专业 Agent、物理设备接口等所有外部能力载体。这一原则让 Agent 能作为 "智能编排者",整合跨领域、跨类型的外部资源,构建更复杂的多 Agent 协作或跨系统交互场景。例如,主 Agent 可将 "图像识别任务" 委托给专用的 "视觉 Agent"(将其视为工具),或通过 API 工具控制智能硬件完成物理世界的操作。

1.3.7 分层调用或者渐进式原则

向 LLM 一次性灌输超过 100 个工具会导致 上下文混淆 (Context Confusion) ,极易引发幻觉或参数错误。Manus 等先进架构通过三层分层设计缓解了这一问题。

其实,Skills 也是这一原则的体现。

1.4 Anthropic 设计高效工具的最佳实践

Anthropic 在其博客中给出了设计高效工具的最佳实践。

  • 选择合适的工具进行实现 (以及不实现哪些工具)
  • 为工具划分命名空间以明确功能边界
  • 从工具向 AI 智能体返回有意义的上下文
  • 优化工具响应的 Token 效率
  • 对工具描述和规格进行提示词 (prompt) 工程

1.5 Agent 工具调用的生命周期

具体落地时,工具使用模式遵循标准化的实现流程:

  • 首先需完成工具定义与注册,将外部函数、API、数据库查询甚至其他 Agent 能力等封装为工具,并把工具的用途、参数等信息注册到工具注册表,供 LLM 感知可用能力;
  • 接着 LLM 接收用户请求后,结合工具信息判断是否需要调用工具及调用何种工具;若决定调用,LLM 生成包含工具名称与参数的结构化请求;
  • 随后框架的编排层依据该请求执行对应工具,获取执行结果并回传给 LLM;
  • 最后 LLM 结合工具结果,要么生成最终响应,要么进一步决策是否继续调用其他工具。

字节跳动技术团队也给出了Agent 工具调用的生命周期的几个阶段,以及设计 Tools 应该考虑的关键要素及方法:

  1. 类型安全与自动化:充分利用 Python 类型系统和 Pydantic,自动处理 schema 生成和数据验证,防止模型"瞎猜"。

    1. 使用 Pydantic BaseModel:利用 Pydantic 进行复杂参数验证,自动处理 Schema 生成和数据验证。
    2. 限制枚举值:通过 Literal 等方式限制可选参数,减少模型出错概率。
    3. 设置默认值:清晰的默认值非常关键,能减轻模型负担并防止响应过大。
  2. LLM 友好的接口设计:LLM 无法像传统程序那样通过技术文档理解接口,它依赖于自然语言描述来决定如何使用工具。

    1. 自然语言优先:使用自然语言描述签名、参数和错误信息,避免使用晦涩的技术术语。
    2. 花费 50% 的时间去打磨 Docstring ,善于用ExamplesSample Case 引导模型准确传参。
    3. 遵守实现"单一责任"原则,不要给模型一个过于复杂的组合接口,而是拆解成参数清晰、职责明确的小型工具,让 Agent 的决策链路更加稳定
  3. 使用 OpenAPI 规范集成外部 API 转化为 Tools:推荐使用OpenAPIToolset 工具集,它可以利用 OperationParser 自动从 OpenAPI spec 生成 function declaration、参数 schema 和请求构建逻辑,实现标准化的快速创建。

  4. 构建自我修复能力,而不是直接终止:工具不应在遇到错误时直接抛出异常导致流程终止,而应引导 Agent 调整策略。

    1. 结构化错误返回包含 error 信息和 recovery_suggestion(修复建议)。
    2. 配合 ReflectAndRetryToolPlugin 等插件拦截错误,提供结构化反思指导,让 Agent 从失败中学习并自动重试。
  5. 加入 Human - in - the - loop(安全防护机制)和关键行为确认。

    1. 通过人工确认,将关键行为的决策权和责任交还给用户。
    2. 通过 require_confirmation 定义工具是否需要开启确认模式。tool_context.tool_confirmation 在敏感操作执行前,验证用户是否已经授权了本次行为。
    3. 当无法决策或缺少关键信息时,ask_human 主动请求用户帮助。
  6. 性能优化与上下文管理:为了保证 Agent 的响应速度并防止上下文溢出,需要对结果进行精细控制。提供多个Tools 给模型调用的时候,可以通过实现异步的方案调用,将串行调用转为并行以加速执行。通过 max_query_result_rows 限制返回数量,或仅返回摘要而非全文,避免 LLM Context 溢出

0x02 OpenHands 的设计

2.1 工具调用引擎

给予智能体一个工具很简单,但让它可靠、安全、有效地使用这个工具,才是真正的难题。工具调用引擎作为智能体连接现实世界的 "手脚",承担着工具管理、交互执行与流程管控的核心职责,其核心功能与实现要点如下:

  1. 交互能力扩展:打通外部工具与资源(API、本地工具、第三方服务等),突破智能体纯文本输出的局限,使其具备操作实体、获取实时数据的能力;
  2. 全生命周期管理:支持工具的注册、查询、更新与卸载,允许动态扩展工具库,适配多样化任务需求;
  3. 全流程自动化:覆盖工具调用的参数校验、格式转换、结果解析与错误处理,无需人工干预即可完成端到端执行。

2.2 核心设计模式

OpenHands V1 工具系统以 "动作 → 执行 → 观察" 三层抽象为核心,构建了类型安全且可扩展的基础框架。其核心逻辑为:

  • 动作:大语言模型生成的 JSON 格式工具调用指令,经 Pydantic 模型校验后转化为标准化 Action 对象;
  • 执行:ToolExecutor 组件接收校验后的 Action 并执行底层操作;
  • 观察:最终执行结果(含正常输出与错误信息)通过 Observation 组件以结构化格式返回,且自动适配大语言模型的理解范式。

这一设计统一了自定义工具与 MCP(模型通信协议)工具的接入标准,为工具的定义、调用与管理提供了单一接口,大幅降低了多类型工具的整合成本。

2.3 鲁棒性与兼容性解决方案

针对工具接口异构、外部环境不稳定、接口变更易引发链路崩溃等核心痛点,OpenHands 设计了三层攻坚方案:

  1. 适配层隔离:通过 Tool Wrapper 工具封装层统一各类工具的输入输出格式,屏蔽原生接口的参数结构、响应方式差异,使上层系统无需关注工具底层实现;
  2. 智能容错机制:基于错误类型分类(网络超时、权限不足、参数非法等),预设重试、降级、回滚等策略,例如网络波动时自动重试,核心工具不可用时切换备用工具;
  3. 版本化管理:支持工具版本标注与适配层动态调整,当工具接口变更时,仅需修改对应 Wrapper 逻辑,无需改动核心执行流程,保障任务链路稳定性。

0x03 功能解析

ReAct框架是一个将思考与行动(调用工具)深度绑定的框架。在这个框架的驱动下,AI在思考过程中如果意识到「我的内部知识不足以支撑下一步决策」,就会主动伸出「search_api」去链接互联网,把动态的客观事实传回大脑,再继续思考。因此Agent Framework的首要职责是设计模型的思考结构、记忆机制和与世界交互的范式。

3.1 流程

function_calling 在总体流程中如下:

  • 工具使用(函数调用)允许 Agent 与外部系统交互并访问动态信息。
  • 它涉及定义具有 LLM 可以理解的清晰描述和参数的工具。
  • LLM 决定何时使用工具并生成结构化函数调用。
  • Agent 框架执行实际的工具调用并将结果返回给 LLM。

具体可以参见下图:

python 复制代码
LLM Response
    ↓
function_calling.response_to_action() (从tool生成Action)
    ↓
具体的Action对象(CmdRunAction,IPythonRunCellAction等)
    ↓
AgentController(调度Action)    
    ↓
Runtime(执行Action)
    ↓
Observation (执行结果)
    ↓
Agent(依据结果做下一步决策)    

3.2 工具注册与管理

3.2.1 如何与 LLM 确定 tool_calls

提示词

工具不是"给模型一个黑盒 API",而是带有严格契约的能力组件

  • 明确输入输出结构
  • 对参数范围、权限、错误做硬约束

对于与外部世界交互的智能体而言,最重要的是**"为工具使用编写提示词"** 。LLM 能否正确使用你提供的工具,几乎完全取决于你如何描述这个工具。一个有效的工具描述必须:

  1. 使用主动动词:以清晰的动作开始(例如,用 get_current_weather 而不是 weather_data)。
  2. 明确输入:清楚地说明需要的参数及其格式(例如:city (string), date (string, YYYY-MM-DD))。
  3. 描述输出:告诉模型会返回什么(例如:"返回一个包含'high', 'low'和'conditions'的 JSON 对象")。
  4. 提及限制:如果工具只在特定区域有效,一定要说明(例如:"注意:仅适用于美国城市。")。
哪些工具

具体到 OpenHands,首先需要明确哪些外部功能或服务可以被调用。这些工具可以是一个原生函数或方法(比如litellm.ChatCompletionToolParam参数形式),也可以是工具类的实例,或者一个智能体的实例。

工具首先需要向大模型进行自我介绍。这是通过一个符合 JSON Schema 规范的配置对象完成的。它详细定义了工具的名称(如read_file)、功能描述(用于读取文件内容),以及最重要的------参数(如 absolute_path、offset等)。这份介绍是模型理解并决定如何使用该工具的依据。

工具使用模式通常通过函数调用机制实现,使 Agent 能连接外部 API、数据库、服务,甚至执行代码。该机制让位于 Agent 核心的大语言模型(LLM)能基于用户请求或任务状态,决策何时以及如何调用特定外部函数。

如果以函数的形式存在,例如查询数据库、调用天气 API 或执行数学计算等。每个工具应包含以下信息:

  • 工具名称(name)
  • 描述(description)
  • 参数定义(parameters),包括参数类型、是否必填等

LLM 会根据函数/工具名称、描述(来自文档字符串或 description 字段)和参数模式,结合对话和指令,决定调用哪个工具。

3.2.2 配置工具列表

其次要将定义好的工具整理成一个列表,并通过 LLM 的接口传入。LLM 会基于这些工具的信息决定在生成响应时是否调用它们。

CodeActAgent 的tools属性会维持工具。

python 复制代码
class CodeActAgent(Agent):
    def __init__(self, config: AgentConfig, llm_registry: LLMRegistry) -> None:
        self.tools = self._get_tools()

_get_tools 具体如下。

python 复制代码
    def _get_tools(self) -> list['ChatCompletionToolParam']:
        # For these models, we use short tool descriptions ( < 1024 tokens)
        # to avoid hitting the OpenAI token limit for tool descriptions.
        SHORT_TOOL_DESCRIPTION_LLM_SUBSTRS = ['gpt-4', 'o3', 'o1', 'o4']

        use_short_tool_desc = False
        if self.llm is not None:
            use_short_tool_desc = any(
                model_substr in self.llm.config.model
                for model_substr in SHORT_TOOL_DESCRIPTION_LLM_SUBSTRS
            )

        tools = []
        if self.config.enable_cmd:
            tools.append(create_cmd_run_tool(use_short_description=use_short_tool_desc))
        if self.config.enable_think:
            tools.append(ThinkTool)
        if self.config.enable_finish:
            tools.append(FinishTool)
        if self.config.enable_condensation_request:
            tools.append(CondensationRequestTool)
        if self.config.enable_browsing:
            tools.append(BrowserTool)
        if self.config.enable_jupyter:
            tools.append(IPythonTool)
        if self.config.enable_plan_mode:
            # In plan mode, we use the task_tracker tool for task management
            tools.append(create_task_tracker_tool(use_short_tool_desc))
        if self.config.enable_llm_editor:
            tools.append(LLMBasedFileEditTool)
        elif self.config.enable_editor:
            tools.append(
                create_str_replace_editor_tool(
                    use_short_description=use_short_tool_desc,
                    runtime_type=self.config.runtime,
                )
            )
        return tools

以 IPythonTool 为例,我们看看如何定义这些工具。

python 复制代码
_IPYTHON_DESCRIPTION = """Run a cell of Python code in an IPython environment.
* The assistant should define variables and import packages before using them.
* The variable defined in the IPython environment will not be available outside the IPython environment (e.g., in terminal).
"""

IPythonTool = ChatCompletionToolParam(
    type='function',
    function=ChatCompletionToolParamFunctionChunk(
        name='execute_ipython_cell',
        description=_IPYTHON_DESCRIPTION,
        parameters={
            'type': 'object',
            'properties': {
                'code': {
                    'type': 'string',
                    'description': 'The Python code to execute. Supports magic commands like %pip.',
                },
                'security_risk': {
                    'type': 'string',
                    'description': SECURITY_RISK_DESC,
                    'enum': RISK_LEVELS,
                },
            },
            'required': ['code', 'security_risk'],
        },
    ),
)

3.2.3 支持的工具类型

在工具规划时,需要控制"颗粒度"和"数量",具体如下:

  • 遵循"最小必要接口":只暴露完成任务必须的参数和功能,去掉无意义或永远是固定值的参数。
  • 避免拆得过碎:从"用户要完成的任务"出发,把强相关步骤打包成一个任务型工具,而不是几十个原子接口。
  • 以"任务导向"而不是"接口罗列"为中心设计工具集,让模型理解"现在要完成什么事"。

OpenHands支持的工具类型如下:

  • 命令行工具(CmdRunTool):执行 Bash 命令
  • IPython 工具(IPythonTool):要运行的IPython代码
  • AgentDelegateAction:将任务委托给浏览智能体(BrowsingAgent)
  • AgentFinishAction:标记任务结束并返回最终思考
  • LLMBasedFileEditTool: LLM 基于文件编辑工具(已废弃)
  • 字符串替换编辑工具:支持文件读取和替换操作
  • AgentThinkAction:记录智能体的思考过程
  • CondensationRequestAction:触发历史上下文精简
  • BrowserTool:执行交互式浏览操作
  • TaskTrackingAction:管理任务列表(计划、更新等)
  • MCPAction:调用 MCP 注册的工具

其中,几个工具的特点如下:

  1. execute_bash
  • 执行任何有效的Linux bash命令
  • 通过将长时间运行的命令在后台运行并重定向输出来处理
  • 支持通过STDIN输入和进程中断的交互式进程
  • 处理命令超时并在后台模式下自动重试
  1. execute_ipython_cell
  • 在IPython环境中运行Python代码
  • 支持如%pip的魔法命令
  • 变量限定在IPython环境中
  • 使用前需要定义变量和导入包
  1. web_readbrowser
  • web_read:读取并转换网页内容为Markdown
  • browser:通过Python代码与网页交互
  • 支持常见的浏览器操作,如导航、点击、填写表单、滚动
  • 处理文件上传和拖放操作
  1. str_replace_editor
  • 通过字符串替换查看、创建和编辑文件
  • 跨命令调用的持久状态
  • 带行号的文件查看
  • 精确匹配的字符串替换
  • 编辑的撤销功能
  1. edit_file(基于LLM)
  • 使用基于LLM的内容生成编辑文件
  • 支持部分文件编辑,具有行范围
  • 通过编辑特定部分处理大文件
  • 向文件添加内容的追加模式

具体代码如下

python 复制代码
from openhands.events.action import (
    Action,
    ActionSecurityRisk,
    AgentDelegateAction,
    AgentFinishAction,
    AgentThinkAction,
    BrowseInteractiveAction,
    CmdRunAction,
    FileEditAction,
    FileReadAction,
    IPythonRunCellAction,
    MessageAction,
    TaskTrackingAction,
)

可以通过配置参数启用/禁用工具:

  • enable_browsing:启用浏览器交互工具
  • enable_jupyter:启用IPython代码执行
  • enable_llm_editor:启用基于LLM的文件编辑(如果禁用,则回退到字符串替换编辑器)

3.3 BrowserTool

BrowserTool 的定义如下:

python 复制代码
for _, action in _browser_action_space.action_set.items():
    assert action.signature in _BROWSER_TOOL_DESCRIPTION, (
        f'Browser description mismatch. Please double check if the BrowserGym updated their action space.\n\nAction: {action.signature}'
    )
    assert action.description in _BROWSER_TOOL_DESCRIPTION, (
        f'Browser description mismatch. Please double check if the BrowserGym updated their action space.\n\nAction: {action.description}'
    )
    
BrowserTool = ChatCompletionToolParam(
    type='function',
    function=ChatCompletionToolParamFunctionChunk(
        name=BROWSER_TOOL_NAME,
        description=_BROWSER_DESCRIPTION,
        parameters={
            'type': 'object',
            'properties': {
                'code': {
                    'type': 'string',
                    'description': (
                        'The Python code that interacts with the browser.\n'
                        + _BROWSER_TOOL_DESCRIPTION
                    ),
                },
                'security_risk': {
                    'type': 'string',
                    'description': SECURITY_RISK_DESC,
                    'enum': RISK_LEVELS,
                },
            },
            'required': ['code', 'security_risk'],
        },
    ),
)

具体元信息如下:

python 复制代码
_BROWSER_DESCRIPTION = """Interact with the browser using Python code. Use it ONLY when you need to interact with a webpage.

See the description of "code" parameter for more details.

Multiple actions can be provided at once, but will be executed sequentially without any feedback from the page.
More than 2-3 actions usually leads to failure or unexpected behavior. Example:
fill('a12', 'example with "quotes"')
click('a51')
click('48', button='middle', modifiers=['Shift'])

You can also use the browser to view pdf, png, jpg files.
You should first check the content of /tmp/oh-server-url to get the server url, and then use it to view the file by `goto("{server_url}/view?path={absolute_file_path}")`.
For example: `goto("http://localhost:8000/view?path=/workspace/test_document.pdf")`
Note: The file should be downloaded to the local machine first before using the browser to view it.
"""

_BROWSER_TOOL_DESCRIPTION = """
The following 15 functions are available. Nothing else is supported.

goto(url: str)
    Description: Navigate to a url.
    Examples:
        goto('http://www.example.com')

go_back()
    Description: Navigate to the previous page in history.
    Examples:
        go_back()

go_forward()
    Description: Navigate to the next page in history.
    Examples:
        go_forward()

noop(wait_ms: float = 1000)
    Description: Do nothing, and optionally wait for the given time (in milliseconds).
    You can use this to get the current page content and/or wait for the page to load.
    Examples:
        noop()

        noop(500)

scroll(delta_x: float, delta_y: float)
    Description: Scroll horizontally and vertically. Amounts in pixels, positive for right or down scrolling, negative for left or up scrolling. Dispatches a wheel event.
    Examples:
        scroll(0, 200)

        scroll(-50.2, -100.5)

 省略其他
"""

3.4 Python 解释器集成

CodeAct 集成了 Python 解释器,使其能够:

  • 动态运行脚本并根据执行结果进行调整。这就像是智能体有了"大脑",可以根据实际情况灵活应变。
  • 利用现有的 Python 库,而不是重新发明特定任务的工具。Python 社区已经积累了大量的工具库,CodeAct 可以直接使用这些"现成的零件",大大提高了效率。
  • 在单个执行周期内处理使用控制流结构(循环、条件语句)的复杂逻辑。这意味着智能体可以处理更复杂的任务,就像我们写程序一样,可以使用循环和判断语句来控制程序的流程。

例如,如果给 LLM 的任务是分析数据集,CodeAct 允许它生成和执行 Python 代码来进行数据清洗、可视化和统计分析 ------ 所有这些都在一个无缝的工作流程中完成。

3.5 解析工具调用

CodeActAgent.step() 中会调用response_to_actions将LLM响应(tool)转换为具体操作列表。

3.5.1 在 CodeActAgent 的使用

step() 作为 CodeAct 智能体的核心执行入口,负责单步动作生成,主要功能包括:

  • 待办动作优先执行:维护动作队列,确保动作顺序执行;
  • 退出条件检测:响应用户 /exit 指令,终止任务;
  • 上下文压缩:通过压缩器筛选冗余历史,优化 LLM 输入效率;
  • LLM 调用适配:组装对话消息、工具配置、元数据,生成合规的 LLM 请求;
  • 响应转动作:将 LLM 输出转换为系统可执行动作,存入队列并返回队首动作。

在 CodeActAgent.step() 方法中,self.llm.completion() 方法会调用底层的 LLM API(如 OpenAI、Anthropic 等)。

复制代码
response = self.llm.completion(**params)

这些 API 的响应会被 LiteLLM 库封装成 ModelResponse 对象,其中包含 choices 属性。choices 属性是一个列表,包含了模型生成的所有候选响应(通常只有一个)。choices 是在 LLM 生成响应的过程中由 LiteLLM 库自动设置的,而不是在 OpenHands 代码中手动设置的。

当 LLM 被触发时,如果启用了工具调用,它会在输出中包含 tool_calls 字段。该字段是一个列表,每个元素描述了一个具体的工具调用请求,包括:

  • 调用的工具名称
  • 传递给工具的参数
  • 其他元数据

OpenHands 会根据 tool_calls 中的信息,实际执行对应的工具函数,并将执行结果返回给 LLM。LLM 可以利用这些结果继续生成更准确的回复。

流程图如下:

代码如下:

python 复制代码
    def step(self, state: State) -> 'Action':
        """使用CodeAct Agent执行一步操作。
        
        包括收集先前步骤的信息,并提示模型生成要执行的命令。
        
        参数:
        - state (State): 用于获取更新的信息
        
        返回:
        - CmdRunAction(command) - 要运行的bash命令
        - IPythonRunCellAction(code) - 要运行的IPython代码
        - AgentDelegateAction(agent, inputs) - 用于(子)任务的委托操作
        - MessageAction(content) - 要运行的消息操作(例如,请求澄清)
        - AgentFinishAction() - 结束交互
        - CondensationAction(...) - 通过遗忘指定事件并可选地提供摘要来压缩对话历史
        - FileReadAction(path, ...) - 从指定路径读取文件内容
        - FileEditAction(path, ...) - 使用基于LLM(已弃用)或基于ACI的编辑方式编辑文件
        - AgentThinkAction(thought) - 记录代理的思考/推理过程
        - CondensationRequestAction() - 请求压缩对话历史
        - BrowseInteractiveAction(browser_actions) - 使用指定操作与浏览器交互
        - MCPAction(name, arguments) - 与MCP服务器工具交互
        """
        # 处理待处理操作(如果有)
        if self.pending_actions:
            # 返回并移除队列中的第一个待处理操作
            return self.pending_actions.popleft()

        # 如果任务已完成,退出
        # 获取最新的用户消息
        latest_user_message = state.get_last_user_message()
        # 若用户输入"/exit",则返回结束操作
        if latest_user_message and latest_user_message.content.strip() == '/exit':
            return AgentFinishAction()

        # 压缩状态中的事件。如果获得视图,将其传递给对话管理器处理;
        # 如果获得压缩事件,则返回该事件而非操作。控制器将立即要求代理使用新视图再次执行步骤
        condensed_history: list[Event] = []
        # 匹配压缩器返回的结果类型
        match self.condenser.condensed_history(state):
            # 若为View类型,提取事件列表作为压缩历史
            case View(events=events):
                condensed_history = events
            # 若为Condensation类型,返回其包含的压缩操作
            case Condensation(action=condensation_action):
                return condensation_action

        # 获取初始用户消息(从状态历史中)
        initial_user_message = self._get_initial_user_message(state.history)
        # 构建用于LLM的消息列表(基于压缩历史和初始用户消息)
        messages = self._get_messages(condensed_history, initial_user_message)
        # 构建LLM调用参数
        params: dict = {
            'messages': messages,  # 消息列表
        }
        # 检查并添加可用工具(根据LLM配置过滤)
        params['tools'] = check_tools(self.tools, self.llm.config)
        # 添加额外元数据(从状态中提取,适配LLM格式)
        params['extra_body'] = {
            'metadata': state.to_llm_metadata(
                model_name=self.llm.config.model, agent_name=self.name
            )
        }
        # 调用LLM获取响应
        response = self.llm.completion(** params)
        # 将LLM响应转换为具体操作列表
        actions = self.response_to_actions(response)
        # 将所有操作添加到待处理队列
        for action in actions:
            self.pending_actions.append(action)
        # 返回并移除队列中的第一个操作
        return self.pending_actions.popleft()

3.5.2 构造tools相关信息

check_tools 作为工具配置的兼容性适配层,主要功能包括:

  • 模型识别:检测 LLM 是否为 Gemini 系列;
  • 字段清理:移除 Gemini 不支持的 default 字段和非兼容格式(仅保留 enumdate-time);
  • 配置保护:深拷贝原始工具列表,避免修改原始配置,保障复用性。
python 复制代码
def check_tools(
    tools: List[ChatCompletionToolParam], llm_config: LLMConfig
) -> List[ChatCompletionToolParam]:
    """检查并修改工具配置,确保与当前 LLM 兼容。

    核心适配逻辑:针对 Gemini 模型移除不支持的字段(默认值、非兼容格式),避免调用报错。

    参数:
        tools: 原始工具配置列表
        llm_config: LLM 配置实例(含模型名称等信息)

    返回:
        适配后的工具配置列表
    """
    # 仅对 Gemini 模型进行特殊处理(不支持默认字段和部分格式)
    if 'gemini' in llm_config.model.lower():
        logger.info(
            f'Removing default fields and unsupported formats from tools for Gemini model {llm_config.model} '
            "since Gemini models have limited format support (only 'enum' and 'date-time' for STRING types)."
        )
        # 深拷贝工具列表,避免修改原始配置
        checked_tools = copy.deepcopy(tools)

        # 遍历每个工具,清理不支持的字段
        for tool in checked_tools:
            if 'function' in tool and 'parameters' in tool['function']:
                parameters = tool['function']['parameters']
                if 'properties' in parameters:
                    # 遍历每个参数属性
                    for prop_name, prop in parameters['properties'].items():
                        # 移除默认值字段(Gemini 不支持)
                        if 'default' in prop:
                            del prop['default']

                        # 移除字符串类型参数的不支持格式
                        # Gemini 仅支持 'enum' 和 'date-time' 格式
                        if prop.get('type') == 'string' and 'format' in prop:
                            supported_formats = ['enum', 'date-time']
                            if prop['format'] not in supported_formats:
                                logger.info(
                                    f'Removing unsupported format "{prop["format"]}" for STRING parameter "{prop_name}"'
                                )
                                del prop['format']
        return checked_tools

    # 非 Gemini 模型:直接返回原始工具配置
    return tools

3.5.3 解析工具调用 LLM响应解析与转换

在 response_to_actions 函数中会解析工具调用。

response_to_actions 是 OpenHands 系统中LLM 响应与系统动作的核心转换桥梁,负责将 LLM 输出的自然语言响应(含工具调用指令)转换为系统可直接执行的标准化动作列表。主要功能包括:

  1. 响应解析:提取 LLM 响应中的文本思考内容和工具调用信息,兼容字符串、文本片段列表等多种内容格式。
  2. 工具映射:根据工具名称,将 LLM 调用的工具映射为对应系统动作(如命令行执行、文件操作、智能体委托等 11 类动作)。
  3. 参数校验与标准化:严格校验每个动作的必填参数,处理可选参数格式转换(如布尔值、超时时间),过滤无效参数,确保动作合法性。
  4. 元数据补充:为动作添加工具调用元数据(调用 ID、函数名等)和响应 ID,便于追踪和关联令牌使用数据。
  5. 异常处理:针对参数解析失败、必填参数缺失、工具未注册等场景,抛出明确的校验异常,保障流程稳健性。
  6. 无工具调用适配:当响应仅含文本内容时,自动创建消息动作,支持用户交互响应。

这里的 response.choices 是从 ModelResponse 对象中获取的,而 ModelResponse 是 LiteLLM 库中的一个类。

流程图如下:

response_to_actions 全部代码如下:

python 复制代码
def response_to_actions(
    response: ModelResponse, mcp_tool_names: Optional[List[str]] = None
) -> List[Action]:
    """将 LLM 模型响应转换为 OpenHands 系统可执行的动作列表。

    核心逻辑:解析模型响应中的工具调用或文本消息,根据工具名称映射为对应的系统动作,
    校验参数合法性,补充元数据,最终返回标准化的动作列表。

    参数:
        response: LLM 输出的原始响应对象(包含工具调用或文本内容)
        mcp_tool_names: MCP 注册的工具名称列表(可选),用于识别 MCP 工具调用

    返回:
        标准化的系统动作列表,可直接被智能体控制器执行
    """
    actions: List[Action] = []
    # 断言响应仅包含一个选项(当前系统仅支持单选项响应)
    assert len(response.choices) == 1, 'Only one choice is supported for now'
    choice = response.choices[0]
    assistant_msg = choice.message

    # 处理包含工具调用的响应
    if hasattr(assistant_msg, 'tool_calls') and assistant_msg.tool_calls:
        # 提取思考内容:支持字符串或文本片段列表两种格式
        thought = ''
        if isinstance(assistant_msg.content, str):
            thought = assistant_msg.content
        elif isinstance(assistant_msg.content, list):
            for msg in assistant_msg.content:
                if msg['type'] == 'text':
                    thought += msg['text']

        # 遍历每个工具调用,转换为对应系统动作
        for i, tool_call in enumerate(assistant_msg.tool_calls):
            action: Action
            logger.debug(f'Tool call in function_calling.py: {tool_call}')

            # 解析工具调用参数(JSON 字符串转字典)
            try:
                arguments = json.loads(tool_call.function.arguments)
            except json.decoder.JSONDecodeError as e:
                # 参数解析失败,抛出校验异常
                raise FunctionCallValidationError(
                    f'Failed to parse tool call arguments: {tool_call.function.arguments}'
                ) from e

            # ================================================
            # 1. 命令行工具(CmdRunTool):执行 Bash 命令
            # ================================================
            if tool_call.function.name == create_cmd_run_tool()['function']['name']:
                # 校验必填参数 "command"
                if 'command' not in arguments:
                    raise FunctionCallValidationError(
                        f'Missing required argument "command" in tool call {tool_call.function.name}'
                    )
                # 转换 "is_input" 参数为布尔值(默认 false)
                is_input = arguments.get('is_input', 'false') == 'true'
                # 创建命令行执行动作
                action = CmdRunAction(command=arguments['command'], is_input=is_input)

                # 处理可选参数 "timeout"(设置硬超时时间)
                if 'timeout' in arguments:
                    try:
                        action.set_hard_timeout(float(arguments['timeout']))
                    except ValueError as e:
                        raise FunctionCallValidationError(
                            f"Invalid float passed to 'timeout' argument: {arguments['timeout']}"
                        ) from e
                # 为动作设置安全风险等级
                set_security_risk(action, arguments)

            # ================================================
            # 2. IPython 工具:执行 Jupyter 代码
            # ================================================
            elif tool_call.function.name == IPythonTool['function']['name']:
                # 校验必填参数 "code"
                if 'code' not in arguments:
                    raise FunctionCallValidationError(
                        f'Missing required argument "code" in tool call {tool_call.function.name}'
                    )
                # 创建 IPython 代码执行动作
                action = IPythonRunCellAction(code=arguments['code'])
                # 设置安全风险等级
                set_security_risk(action, arguments)

            # ================================================
            # 3. AgentDelegateAction:将任务委托给浏览智能体(BrowsingAgent)
            # ================================================
            elif tool_call.function.name == 'delegate_to_browsing_agent':
                action = AgentDelegateAction(
                    agent='BrowsingAgent',
                    inputs=arguments,
                )

            # ================================================
            # 4. AgentFinishAction:标记任务结束并返回最终思考
            # ================================================
            elif tool_call.function.name == FinishTool['function']['name']:
                action = AgentFinishAction(
                    final_thought=arguments.get('message', ''),
                )

            # ==================================================
            # 5. LLMBasedFileEditTool: LLM 基于文件编辑工具(已废弃)
            # ================================================
            elif tool_call.function.name == LLMBasedFileEditTool['function']['name']:
                # 校验必填参数 "path" 和 "content"
                if 'path' not in arguments:
                    raise FunctionCallValidationError(
                        f'Missing required argument "path" in tool call {tool_call.function.name}'
                    )
                if 'content' not in arguments:
                    raise FunctionCallValidationError(
                        f'Missing required argument "content" in tool call {tool_call.function.name}'
                    )
                # 创建文件编辑动作(直接写入内容模式)
                action = FileEditAction(
                    path=arguments['path'],
                    content=arguments['content'],
                    start=arguments.get('start', 1),  # 默认起始行 1
                    end=arguments.get('end', -1),    # 默认结束行 -1(全文)
                    impl_source=arguments.get(
                        'impl_source', FileEditSource.LLM_BASED_EDIT
                    ),
                )

            # ================================================
            # 6. 字符串替换编辑工具:支持文件读取和替换操作
            # ================================================
            elif (
                tool_call.function.name
                == create_str_replace_editor_tool()['function']['name']
            ):
                # 校验必填参数 "command" 和 "path"
                if 'command' not in arguments:
                    raise FunctionCallValidationError(
                        f'Missing required argument "command" in tool call {tool_call.function.name}'
                    )
                if 'path' not in arguments:
                    raise FunctionCallValidationError(
                        f'Missing required argument "path" in tool call {tool_call.function.name}'
                    )
                path = arguments['path']
                command = arguments['command']
                # 提取除 "command" 和 "path" 外的其他参数
                other_kwargs = {
                    k: v for k, v in arguments.items() if k not in ['command', 'path']
                }

                # 命令为 "view":创建文件读取动作
                if command == 'view':
                    action = FileReadAction(
                        path=path,
                        impl_source=FileReadSource.OH_ACI,
                        view_range=other_kwargs.get('view_range', None),  # 可选读取范围
                    )
                # 其他命令:创建文件编辑动作(替换模式)
                else:
                    # 移除不需要的 "view_range" 参数
                    if 'view_range' in other_kwargs:
                        other_kwargs.pop('view_range')

                    # 过滤无效参数(仅保留工具定义中允许的参数)
                    valid_kwargs_for_editor = {}
                    str_replace_editor_tool = create_str_replace_editor_tool()
                    valid_params = set(
                        str_replace_editor_tool['function']['parameters']['properties'].keys()
                    )

                    for key, value in other_kwargs.items():
                        if key in valid_params:
                            # "security_risk" 是合法参数,但不传入编辑动作
                            if key != 'security_risk':
                                valid_kwargs_for_editor[key] = value
                        else:
                            raise FunctionCallValidationError(
                                f'Unexpected argument {key} in tool call {tool_call.function.name}. Allowed arguments are: {valid_params}'
                            )

                    # 创建文件编辑动作(替换模式)
                    action = FileEditAction(
                        path=path,
                        command=command,
                        impl_source=FileEditSource.OH_ACI,
                        **valid_kwargs_for_editor,
                    )

                # 为动作设置安全风险等级
                set_security_risk(action, arguments)

            # ================================================
            # 7. AgentThinkAction:记录智能体的思考过程
            # ================================================
            elif tool_call.function.name == ThinkTool['function']['name']:
                action = AgentThinkAction(thought=arguments.get('thought', ''))

            # ================================================
            # 8. CondensationRequestAction:触发历史上下文精简
            # ================================================
            elif tool_call.function.name == CondensationRequestTool['function']['name']:
                action = CondensationRequestAction()

            # ================================================
            # 9. BrowserTool:执行交互式浏览操作
            # ================================================
            elif tool_call.function.name == BrowserTool['function']['name']:
                # 校验必填参数 "code"
                if 'code' not in arguments:
                    raise FunctionCallValidationError(
                        f'Missing required argument "code" in tool call {tool_call.function.name}'
                    )
                action = BrowseInteractiveAction(browser_actions=arguments['code'])
                set_security_risk(action, arguments)

            # ================================================
            # 10. TaskTrackingAction:管理任务列表(计划、更新等)
            # ================================================
            elif tool_call.function.name == TASK_TRACKER_TOOL_NAME:
                # 校验必填参数 "command"
                if 'command' not in arguments:
                    raise FunctionCallValidationError(
                        f'Missing required argument "command" in tool call {tool_call.function.name}'
                    )
                # "plan" 命令需额外校验 "task_list"
                if arguments['command'] == 'plan' and 'task_list' not in arguments:
                    raise FunctionCallValidationError(
                        f'Missing required argument "task_list" for "plan" command in tool call {tool_call.function.name}'
                    )

                raw_task_list = arguments.get('task_list', [])
                # 校验 "task_list" 格式为列表
                if not isinstance(raw_task_list, list):
                    raise FunctionCallValidationError(
                        f'Invalid format for "task_list". Expected a list but got {type(raw_task_list)}.'
                    )

                # 标准化任务列表:确保每个任务为字典且包含必要字段
                normalized_task_list = []
                for i, task in enumerate(raw_task_list):
                    if isinstance(task, dict):
                        normalized_task = {
                            'id': task.get('id', f'task-{i + 1}'),  # 自动生成ID(无则补全)
                            'title': task.get('title', 'Untitled task'),  # 默认标题
                            'status': task.get('status', 'todo'),  # 默认状态为待办
                            'notes': task.get('notes', ''),  # 默认备注为空
                        }
                    else:
                        # 格式非法,抛出异常
                        logger.warning(
                            f'Unexpected task format in task_list: {type(task)} - {task}'
                        )
                        raise FunctionCallValidationError(
                            f'Unexpected task format in task_list: {type(task)}. Each task shoud be a dictionary.'
                        )
                    normalized_task_list.append(normalized_task)

                # 创建任务跟踪动作
                action = TaskTrackingAction(
                    command=arguments['command'],
                    task_list=normalized_task_list,
                )

            # ================================================
            # 11. MCPAction:调用 MCP 注册的工具
            # ================================================
            elif mcp_tool_names and tool_call.function.name in mcp_tool_names:
                action = MCPAction(
                    name=tool_call.function.name,
                    arguments=arguments,
                )

            # ================================================
            # 未知工具:抛出未注册异常
            # ================================================
            else:
                raise FunctionCallNotExistsError(
                    f'Tool {tool_call.function.name} is not registered. (arguments: {arguments}). Please check the tool name and retry with an existing tool.'
                )

            # 仅为第一个动作添加思考内容(避免重复)
            if i == 0:
                action = combine_thought(action, thought)
            # 为动作添加工具调用元数据(用于追踪和日志)
            action.tool_call_metadata = ToolCallMetadata(
                tool_call_id=tool_call.id,
                function_name=tool_call.function.name,
                model_response=response,
                total_calls_in_response=len(assistant_msg.tool_calls),
            )
            # 将动作添加到结果列表
            actions.append(action)

    # 无工具调用:创建消息动作(传递文本内容,等待用户响应)
    else:
        actions.append(
            MessageAction(
                content=str(assistant_msg.content) if assistant_msg.content else '',
                wait_for_response=True,
            )
        )

    # 为所有动作添加响应 ID(用于关联令牌使用数据)
    for action in actions:
        action.response_id = response.id

    # 断言至少返回一个动作(确保流程有效性)
    assert len(actions) >= 1
    return actions

0xFF 参考

https://www.zhihu.com/question/1959742114519844109/answer/1983526566437880277

这是一本40页的上下文工程ebook

一文读懂 Agent Tools,拒绝复杂化、碎片化、黑盒化