news 2026/10/28 14:35:03

无Tool Calling的结构化Agent:纯文本协议实现ReAct循环

作者头像

张小明

前端开发工程师

1.2k 24
文章封面图
无Tool Calling的结构化Agent:纯文本协议实现ReAct循环

1. 为什么我要做“无 Tool Calling”的结构化 Agent

1.1 从一次 Prompt 被拦截说起

前段时间在调试一个 Agent 项目时,我遇到了一个很典型的问题:模型返回了invalid prompt: your prompt was flagged as potentially violating our usage policy。排查了半天,发现根源不在提示词本身,而在于我为了让模型调用工具,在 Prompt 里塞了大量结构化的函数描述、参数 schema 和调用示例。这些内容叠加起来,触发了平台的安全过滤机制。

这件事让我重新思考一个问题:Agent 一定要依赖 Tool Calling 吗?

现在主流的 Agent 框架,无论是 ReAct 模式还是各种 function calling 方案,核心思路都是让模型输出一个结构化的调用指令,然后由外部运行时去执行。这个方案很成熟,但它有几个绕不开的痛点:

  • 平台依赖强:不同模型厂商的 tool calling 格式不统一,换一个模型就要改一遍适配层。
  • Prompt 膨胀严重:每个工具都要写完整的 JSON Schema,工具一多,光工具描述就吃掉几千 token。
  • 调试困难:模型输出的调用参数一旦格式错误,整个链路就断了,而且错误信息往往很模糊。
  • 安全过滤误伤:大量结构化描述容易触发平台的内容审核,就像我遇到的那样。

所以我决定做一个不依赖 Tool Calling 的结构化通用 Agent。它的核心思路是:用纯文本协议来约定模型的输出格式,由 Agent 自己解析文本、执行动作、把结果拼回上下文,形成一个完整的 ReAct 循环。整个过程不依赖任何厂商特有的 function calling 接口,只要模型能稳定输出文本,就能跑起来。

1.2 这个 Agent 到底能做什么

说白了,这个 Agent 就是一个能思考、能行动、能观察结果的文本循环机器。你给它一个任务,它会:

  1. 先思考当前该做什么(Thought)
  2. 决定调用哪个动作(Action),并给出参数
  3. 由外部执行这个动作,拿到结果(Observation)
  4. 把结果拼回上下文,继续下一轮思考
  5. 直到它认为任务完成,输出最终答案(Final Answer)

这套流程就是经典的 ReAct 模式,但我不使用任何 tool calling 接口,全部靠 Prompt 约定 + 文本解析来实现。这样做的好处是:

  • 模型无关:不管是哪个厂商的模型,只要能输出规范文本就能用。
  • 协议透明:整个交互过程都是可读的文本,出问题一眼就能看出是哪一步错了。
  • 易于扩展:加一个新工具,只需要在 Prompt 里加一段说明,不需要改代码里的 schema。
  • Token 可控:工具描述用自然语言写,比 JSON Schema 省很多 token。

适合谁来参考?如果你正在做 Agent 开发,被 tool calling 的兼容性问题折磨过,或者想理解 ReAct 模式的底层实现,这篇文章应该能给你一些可以直接抄作业的思路。

1.3 整体架构长什么样

在动手写代码之前,我先把整个架构理清楚。这个 Agent 由四个核心模块组成:

模块职责关键设计
Prompt 构造器拼接系统提示、工具说明、历史对话用纯文本协议描述工具,避免结构化 schema
模型调用层发送 Prompt,获取模型输出统一接口,屏蔽不同厂商差异
输出解析器从模型输出中提取 Thought/Action/Final Answer正则 + 状态机,容错处理
执行器根据 Action 调用对应工具,返回 Observation工具注册表,动态分发

整个循环的控制逻辑是:构造 Prompt → 调用模型 → 解析输出 → 如果是 Action 就执行并拼回上下文 → 如果是 Final Answer 就结束。这个循环最多跑 N 轮,防止死循环。

提示:轮数上限一定要设,我一般设 10 到 15 轮。超过这个数还没出结果,基本说明任务描述有问题或者工具不够用,继续跑只是浪费 token。

2. 核心细节解析:文本协议怎么设计才稳

2.1 为什么不用 JSON 而用纯文本协议

很多人第一反应是:既然要结构化,为什么不用 JSON?让模型输出 JSON,解析起来多方便。

我试过,结论是:JSON 在 Agent 场景下反而更脆弱。

原因有几个。第一,模型输出 JSON 时经常会在前后加解释性文字,比如“好的,我来调用这个工具:{...}”,你得先做文本清洗才能解析。第二,JSON 对格式要求严格,少一个引号、多一个逗号就解析失败,而模型在长上下文里很容易犯这种错。第三,JSON 里的转义字符处理很麻烦,尤其是参数里包含引号或换行的时候。

纯文本协议就不一样了。我用的是类似下面这种格式:

Thought: 我需要先查一下当前时间 Action: get_current_time Action Input: {} Observation: 2024-01-15 10:30:00 Thought: 现在我知道时间了,可以回答用户 Final Answer: 现在是2024年1月15日上午10点30分

这种格式的好处是:模型只需要按行输出,每行一个字段,解析器按行读取、按前缀匹配就行。即使模型多输出了一些解释文字,只要关键的Action:和Action Input:行存在,就能提取出来。

2.2 工具描述怎么写才省 token 又清晰

工具描述是 Prompt 里最占篇幅的部分。用 JSON Schema 写一个工具,动辄十几行;用自然语言写,两三行就够了。

我举个例子。假设有一个查询天气的工具,JSON Schema 版本是这样的:

{ "name": "get_weather", "description": "查询指定城市的天气", "parameters": { "type": "object", "properties": { "city": { "type": "string", "description": "城市名称" }, "unit": { "type": "string", "enum": ["celsius", "fahrenheit"], "description": "温度单位" } }, "required": ["city"] } }

而自然语言版本只需要:

get_weather: 查询指定城市的天气。参数:city(城市名称,必填),unit(温度单位,可选,默认celsius)

同样的信息量,token 数少了将近一半。工具一多,这个差距就很可观了。我实测下来,10 个工具的情况下,自然语言描述比 JSON Schema 能省 40% 左右的 token。

注意:自然语言描述虽然省 token,但一定要把参数名、是否必填、默认值写清楚。模型对参数的理解全靠这段描述,写模糊了它就会瞎猜。

2.3 解析器的容错设计

解析器是整个 Agent 里最容易出问题的地方,因为模型的输出永远比你想象的更“自由”。我踩过的坑包括:

  • 模型把Action:写成action:或Action:(中文冒号)
  • 模型在Action Input:后面输出多行内容
  • 模型忘记输出Thought:,直接跳到Action:
  • 模型在Final Answer:后面又跟了一段解释

所以解析器必须做容错。我的做法是:

  1. 大小写不敏感:所有前缀匹配都转小写再比。
  2. 冒号兼容:同时匹配英文冒号和中文冒号。
  3. 多行参数处理:Action Input后面的内容一直读到下一个已知前缀(如Observation:或Thought:)为止。
  4. 优先级判断:先找Final Answer,找到就直接结束;没有再找Action。
  5. 失败兜底:如果既没有Final Answer也没有Action,就把整段输出当作思考内容,追加一条提示让模型重新输出。

这套容错逻辑写下来大概一百多行代码,但能挡掉 90% 以上的格式问题。

2.4 上下文管理的关键取舍

Agent 跑多轮之后,上下文会越来越长。如果不做管理,很快就会超出模型的上下文窗口。我试过几种方案:

方案优点缺点适用场景
全量保留信息完整快速膨胀短任务(3轮以内)
滑动窗口实现简单丢失早期信息中等任务
摘要压缩保留关键信息需要额外模型调用长任务
只保留最近N轮+首轮平衡可能丢中间关键步骤通用

我最终选的是只保留最近 N 轮 + 首轮系统提示的方案。因为 Agent 的每一轮 Observation 通常已经包含了前序步骤的关键结果,早期的思考过程参考价值不大。N 我一般设 5,也就是保留最近 5 轮的完整交互。

提示:如果你的任务需要模型记住很早之前的某个中间结果,可以在 Observation 里显式提醒,比如“注意:之前查询到的用户ID是12345”。这比保留全部历史更省 token。

3. 实操过程:从零搭一个能跑的 Agent

3.1 环境准备与依赖安装

这个项目用 Python 写,依赖很少,核心就一个 HTTP 请求库。我用的是requests,如果你喜欢异步可以用httpx。

pip install requests

Python 版本建议 3.9 以上,因为用到了dict的合并操作符和一些类型注解语法。如果你还没装 Python,去官网下载安装包,安装时记得勾选“Add Python to PATH”,不然命令行里调不到。

装完验证一下:

python --version pip list

能看到版本号就说明环境没问题。如果你要用 numpy 做数值处理,可以顺手装上:

pip install numpy

但这个 Agent 本身不需要 numpy,纯文本处理用标准库就够了。

3.2 工具注册表的设计

工具注册表是整个 Agent 的“能力清单”。每个工具就是一个函数,加上一段描述。我用一个字典来管理:

TOOLS = {} def register_tool(name, description): def decorator(func): TOOLS[name] = { "func": func, "description": description } return func return decorator @register_tool( "get_current_time", "获取当前时间。参数:无" ) def get_current_time(): from datetime import datetime return datetime.now().strftime("%Y-%m-%d %H:%M:%S") @register_tool( "calculate", "执行数学计算。参数:expression(数学表达式,如 1+2*3)" ) def calculate(expression): try: result = eval(expression, {"__builtins__": {}}, {}) return str(result) except Exception as e: return f"计算错误:{e}"

这里有几个设计要点:

  • 装饰器注册:加工具只需要写一个函数加一个装饰器,不用改其他地方。
  • 描述即文档:描述会直接拼进 Prompt,所以要用自然语言写清楚参数。
  • 返回值统一为字符串:不管工具内部返回什么,最终都转成字符串,方便拼进 Observation。
  • 异常捕获:工具内部一定要捕获异常,返回错误信息而不是抛出去,否则整个 Agent 会崩。

注意:eval有安全风险,生产环境一定要做沙箱或者用ast.literal_eval替代。我这里只是为了演示,实际项目里我用的是一个受限的表达式解析器。

3.3 Prompt 模板的完整写法

Prompt 模板决定了模型能不能稳定输出规范格式。我的模板分三部分:系统提示、工具说明、历史对话。

SYSTEM_PROMPT = """你是一个能思考并行动的智能助手。你可以使用以下工具来完成任务: {tools} 请严格按照以下格式输出: Thought: 你的思考过程 Action: 要使用的工具名称,必须是[{tool_names}]中的一个 Action Input: 工具的输入参数 当你得到足够信息后,输出: Thought: 我已经知道答案了 Final Answer: 你的最终回答 重要规则: 1. 每次只能输出一个Action 2. Action Input必须是合法的参数格式 3. 如果不需要工具,直接输出Final Answer 4. 不要编造工具名称 """

这个模板有几个关键点:

  • 格式说明要具体:明确告诉模型每一行写什么,前缀是什么。
  • 工具名称列表:把可用工具名单独列出来,防止模型编造。
  • 规则约束:明确“每次只能一个 Action”,避免模型一次输出多个动作导致解析混乱。
  • Final Answer 触发条件:告诉模型什么时候该结束。

工具说明的拼接:

def build_tools_description(): lines = [] for name, info in TOOLS.items(): lines.append(f"- {name}: {info['description']}") return "\n".join(lines)

历史对话的拼接:

def build_history(history): parts = [] for item in history: parts.append(item) return "\n\n".join(parts)

最终 Prompt 就是系统提示 + 历史对话。历史对话里包含了之前的 Thought、Action、Observation,模型看到这些就知道自己走到哪一步了。

3.4 模型调用与输出解析

模型调用层我封装成一个函数,屏蔽不同厂商的差异:

def call_model(prompt): response = requests.post( API_URL, headers={"Authorization": f"Bearer {API_KEY}"}, json={ "model": MODEL_NAME, "messages": [{"role": "user", "content": prompt}], "temperature": 0 } ) return response.json()["choices"][0]["message"]["content"]

temperature设 0 是为了让输出更稳定,减少格式漂移。

解析器是核心,我用正则加状态机的方式实现:

import re def parse_output(text): text_lower = text.lower() # 优先找 Final Answer final_match = re.search(r'final answer[::]\s*(.+)', text, re.IGNORECASE | re.DOTALL) if final_match: return {"type": "final", "content": final_match.group(1).strip()} # 找 Action action_match = re.search(r'action[::]\s*(\w+)', text, re.IGNORECASE) if not action_match: return {"type": "unknown", "content": text} action_name = action_match.group(1).strip() # 找 Action Input input_match = re.search( r'action input[::]\s*(.+?)(?=\n(?:thought|observation|action)[::]|$)', text, re.IGNORECASE | re.DOTALL ) action_input = input_match.group(1).strip() if input_match else "" return { "type": "action", "action": action_name, "input": action_input }

这个解析器的容错点:

  • 大小写不敏感
  • 中英文冒号都支持
  • Action Input 用非贪婪匹配,遇到下一个前缀就停
  • 找不到 Action 就返回 unknown,交给上层处理

3.5 主循环的完整实现

把上面的模块串起来,主循环大概是这样:

def run_agent(task, max_steps=10): history = [] history.append(f"任务:{task}") for step in range(max_steps): prompt = build_prompt(history) output = call_model(prompt) history.append(output) parsed = parse_output(output) if parsed["type"] == "final": return parsed["content"] if parsed["type"] == "action": action_name = parsed["action"] action_input = parsed["input"] if action_name not in TOOLS: observation = f"错误:未知工具 {action_name}" else: try: observation = TOOLS[action_name]["func"](action_input) except Exception as e: observation = f"工具执行错误:{e}" history.append(f"Observation: {observation}") continue # unknown 类型,提示模型重新输出 history.append("Observation: 你的输出格式不正确,请严格按照 Thought/Action/Action Input 格式重新输出。") return "达到最大步数限制,任务未完成。"

这个循环的逻辑很清晰:每轮构造 Prompt、调模型、解析、执行、拼回历史。直到出现 Final Answer 或者达到步数上限。

3.6 一次完整的运行记录

我拿一个实际任务跑一遍,看看输出长什么样。任务是:“帮我算一下 (15 + 27) * 3 等于多少,然后告诉我现在的时间。”

第一轮模型输出:

Thought: 我需要先计算这个数学表达式 Action: calculate Action Input: (15 + 27) * 3

执行 calculate,得到 Observation: 126。拼回历史。

第二轮模型输出:

Thought: 计算结果是126,现在我需要获取当前时间 Action: get_current_time Action Input: {}

执行 get_current_time,得到 Observation: 2024-01-15 10:30:00。拼回历史。

第三轮模型输出:

Thought: 我已经得到了计算结果和时间 Final Answer: (15 + 27) * 3 = 126,当前时间是2024年1月15日10点30分。

解析到 Final Answer,循环结束,返回结果。整个过程三轮,干净利落。

4. 常见问题与排查技巧实录

4.1 模型不按格式输出怎么办

这是最常见的问题。表现是模型输出了一大段自然语言,但没有Action:或Final Answer:前缀。

排查思路:

  1. 检查 Prompt 里的格式说明是否够明确。我一开始只写了“请按格式输出”,模型经常忽略。后来改成“请严格按照以下格式输出”,并加了具体示例,稳定性明显提升。
  2. 降低 temperature。temperature 越高,模型越“自由”,格式漂移越严重。设 0 或 0.1 最稳。
  3. 加 few-shot 示例。在 Prompt 里放一两个完整的输入输出示例,模型会模仿这个格式。
  4. 兜底重试。解析失败时,追加一条“格式错误,请重新输出”的提示,让模型再试一次。我实测下来,重试一次能解决大部分问题。

提示:如果模型反复不按格式输出,可能是任务本身太复杂,模型在“思考”阶段就卡住了。这时候可以把任务拆小,分步给模型。

4.2 Action Input 解析错误的处理

Action Input 是解析的重灾区。常见问题包括:

问题现象原因解决方法
参数被截断正则贪婪匹配用非贪婪 + 前缀边界
参数包含换行模型输出多行DOTALL 模式匹配
参数是 JSON 但格式错模型手写 JSON尝试 json.loads,失败则当纯文本
参数为空模型忘记填检查工具是否真的需要参数

我的处理策略是:先尝试按 JSON 解析,失败就按纯文本处理。因为有些工具的参数就是简单字符串,不需要 JSON。比如calculate工具,参数就是表达式本身,直接当字符串用就行。

def parse_action_input(raw): raw = raw.strip() if raw.startswith("{") or raw.startswith("["): try: import json return json.loads(raw) except: pass return raw

4.3 死循环的预防与中断

Agent 死循环是很烦人的问题。表现是模型反复调用同一个工具,或者在不同工具之间来回跳,就是不输出 Final Answer。

我的预防措施:

  • 步数上限:硬性限制最大轮数,超过就强制结束。
  • 重复检测:记录最近几轮的 Action,如果连续两轮完全一样,就追加提示“你已经调用过这个工具了,请基于已有结果继续”。
  • 进度提示:在 Observation 里加一句“当前是第 N 步,最多 10 步”,让模型有紧迫感。

实测下来,加了重复检测之后,死循环的概率下降了很多。因为模型很多时候是“忘了”自己已经调用过某个工具,提醒一下就好了。

4.4 工具执行超时的处理

有些工具可能执行很慢,比如网络请求或者复杂计算。如果不做超时处理,整个 Agent 会卡住。

我的做法是给每个工具加一个超时包装:

import signal def timeout_handler(signum, frame): raise TimeoutError("工具执行超时") def run_with_timeout(func, args, timeout=10): signal.signal(signal.SIGALRM, timeout_handler) signal.alarm(timeout) try: result = func(args) signal.alarm(0) return result except TimeoutError: return "工具执行超时,请尝试其他方法"

这样即使某个工具卡住,Agent 也能继续往下走,不会整个挂掉。

4.5 常见问题速查表

问题可能原因快速排查
模型不输出 ActionPrompt 格式说明不清检查系统提示,加示例
解析不到 Final Answer前缀拼写错误看原始输出,确认前缀
工具名不存在模型编造工具检查工具列表是否拼进 Prompt
参数解析失败格式不匹配打印原始 Action Input
死循环模型忘记进度加重复检测和步数提示
上下文超长历史未裁剪启用滑动窗口
输出被安全拦截Prompt 含敏感结构简化工具描述,避免结构化 schema

最后一条是我踩过的最大的坑。之前用 JSON Schema 描述工具,Prompt 里全是"type": "object"、"properties"这类结构化内容,结果频繁触发平台的安全过滤。改成自然语言描述之后,这个问题就再也没出现过。所以如果你也遇到invalid prompt的报错,第一件事就是检查 Prompt 里有没有大量结构化内容。

5. 一些扩展思路和踩坑心得

5.1 怎么加新工具最省事

加新工具只需要三步:写函数、加装饰器、写描述。不需要改 Prompt 模板,不需要改解析器,不需要改主循环。这就是文本协议的好处——工具和框架完全解耦。

我一般会把工具按类别分组,比如“时间类”“计算类”“查询类”,在 Prompt 里也按组展示。这样模型找工具更快,也不容易选错。

5.2 多轮对话场景的适配

如果要做多轮对话,只需要把历史对话也拼进 Prompt。但要注意,多轮对话的历史和 Agent 的思考历史是两回事。我的做法是:

  • 用户和 Agent 的对话历史单独存
  • Agent 内部的 Thought/Action/Observation 循环结束后,只把 Final Answer 存进对话历史
  • 下一轮对话时,把对话历史拼在任务描述前面

这样既保留了上下文,又不会让 Agent 的内部思考污染对话记录。

5.3 性能优化的几个点

Agent 跑起来之后,性能瓶颈主要在模型调用上。每一轮都要调一次模型,轮数越多越慢。优化方向:

  • 减少轮数:把能合并的工具调用合并,比如一次查询多个信息。
  • 缓存结果:相同参数的相同工具调用,直接返回缓存结果。
  • 并行执行:如果模型一次输出了多个独立 Action,可以并行执行。但这需要改解析器支持多 Action,复杂度会上升。

我目前只做了缓存,效果已经很明显。同一个任务重复跑,第二轮开始就快很多。

5.4 关于 Agent 安全的一点思考

Agent 能调用工具,就意味着它能对真实世界产生影响。所以安全边界一定要划清楚:

  • 工具白名单:只注册必要的工具,不要图省事把所有函数都暴露出去。
  • 参数校验:工具内部一定要校验参数,防止注入。
  • 权限控制:涉及写操作的工具,加确认机制。
  • 日志记录:每一轮的 Thought、Action、Observation 都记下来,出问题能追溯。

我个人的原则是:读操作可以放开,写操作必须谨慎。查询类工具随便调,但涉及修改、删除、发送的工具,一定要加二次确认。

这套无 Tool Calling 的结构化 Agent 我用了大半年,从简单的计算任务到复杂的多步查询都能跑。最大的感受是:协议越简单,系统越稳。不用 tool calling,不用 JSON Schema,就用最朴素的文本格式,反而少了很多兼容性和调试上的麻烦。如果你也在做 Agent 开发,不妨试试这个思路,说不定能省下不少折腾的时间。

版权声明: 本文来自互联网用户投稿,该文观点仅代表作者本人,不代表本站立场。本站仅提供信息存储空间服务,不拥有所有权,不承担相关法律责任。如若内容造成侵权/违法违规/事实不符,请联系邮箱:809451989@qq.com进行投诉反馈,一经查实,立即删除!
网站建设 2026/10/28 14:35:03

MATLAB光学衍射模拟:积分法与傅立叶变换实战

简介:这份资源面向学习大学物理光学、需要动手验证衍射与干涉理论的学生和教师,用 MATLAB 编程把抽象的光学现象转化为可视化的光强分布图。内容围绕两条主线展开:一是衍射积分方法,涵盖单缝衍射、杨氏双缝干涉与黑白光栅衍射的完…

作者头像 李华
网站建设 2026/10/28 14:34:20

智能体技能系统设计实战:从工具封装到多技能编排

最近在折腾智能体工程化落地,翻到一个很值得聊的命名——agent-skills。很多人把它理解成"给Agent加工具",实际动手之后会发现,技能系统要解决的根本不是"能不能调API"这种单点问题,而是"智能体如何知道…

作者头像 李华
网站建设 2026/10/28 14:33:37

合并有序数组:双指针技巧与算法优化

1. 问题背景与核心需求合并两个有序数组是算法面试中的经典问题,也是LeetCode题库中高频出现的题型之一(编号88)。这个问题看似简单,却考察了程序员对数组操作、双指针技巧以及边界条件处理的综合能力。在实际开发中,合…

作者头像 李华
网站建设 2026/10/28 14:33:09

项目管理知识体系PDF解析:条款编号抽取到可检索清单

简介:《项目管理知识体系》PDF面向备考PMP认证的考生,以及需要系统梳理项目管理框架的从业者,围绕PMBOK第六版展开,帮助建立从概念到考点的整体认知。内容覆盖启动、规划、执行、监控、收尾五大过程组,以及范围、进度、…

作者头像 李华
网站建设 2026/10/28 14:33:08

从条件概率到假设检验:一条贯穿统计决策的逻辑链

1. 这不是复习提纲,而是一张“概率决策地图”:为什么从条件概率到假设检验必须连成一条线你有没有过这种感觉:学完条件概率,觉得贝叶斯公式推得挺顺;转头学随机变量,又沉迷于分布函数的几何意义&#xff1b…

作者头像 李华