跳到主要内容

两种 agent 后端:ReAct 与 CIAYN(代码即工具调用)

30 秒导读: RA.Aid 的四个 agent(研究/规划/实现/GC)都不直接绑死某一种"跑法"。同一套工具函数,面对不同能力的模型会被装进两种截然不同的后端:强模型走 LangGraph 官方的 create_react_agent(模型原生会发结构化 tool call),弱模型走自研的 CiaynAgent——干脆让模型吐一行 Python 代码当工具调用,用 ast 校验它确实是"单个函数调用"后 eval 执行。这一章讲清"分叉在哪、怎么分、CIAYN 为什么这么设计、每个校验步骤在防哪个坑"。

本章是 RA.Aid 里工程含量最高的一支。上一章 三阶段编排 讲了"谁在什么时候被调用",本章讲"被调用的那个 agent 内部到底怎么把模型的话变成真实动作"。


1. 这是什么:为什么需要两种后端

先说要解决的痛点。 一个 coding agent 的核心动作是"让模型选一个工具、填好参数、执行、把结果喂回去",循环往复。问题是:模型的能力参差不齐。

  • 强模型(Claude、GPT-4o 等)原生支持 function calling(工具调用)——你把工具的 JSON schema 给它,它会返回结构化的"我要调用 read_file,参数是 {path: ...}"。干净、可靠。
  • 弱模型 / 老模型 / 某些开源模型根本不支持结构化工具调用,或支持得很烂。给它 schema 它也不会好好填。

朴素做法是"只支持强模型"。RA.Aid 不愿意——它想让同一套工具在弱模型上也能跑。于是有了两条后端:

后端实现适用模型工具怎么"调"
ReActLangGraph create_react_agent原生支持 function calling模型发结构化 tool call,框架执行
CIAYN自研 CiaynAgent不支持 / 不确定模型吐一行 Python 代码,exec 执行

CIAYN = Code Is All You Need(代码即一切)。核心赌注:任何能写代码的模型,都会写 read_file_tool("a.py") 这样一行函数调用——那就把"调工具"降维成"写一行代码",绕开对结构化工具调用的依赖。

用起来什么样(直觉)。 想象你给一个不会用工具菜单的模型下指令,它回你一句纯文本:

read_file_tool("src/main.py")

CIAYN 收到这一行,先用 Python 的 ast 确认"这确实是一个函数调用、不是别的鬼东西",然后在一个"只放了工具函数"的命名空间里 eval 掉它,把返回值当作观察结果塞回对话——下一轮模型接着写下一行代码。这就是"代码即工具调用"。


2. 顶层全景:一次后端分叉

所有 agent 的入口都是 agent_utils.py:create_agent(agent_utils.py:133-225)。它是那个分叉路口:拿到 modeltools,判断该走哪条后端,返回一个能 .stream() 的 agent 对象。

怎么读这张图:从上往下是一次 create_agent 调用;中间的菱形是唯一的判断点。

create_agent(model, tools) agent_utils.py:133

│ max_input_tokens = get_model_token_limit(...) or DEFAULT
│ build_agent_kwargs(...) agent_utils.py:81

┌─────────────────────────┐
│ should_use_react_agent │ model_detection.py:117
│ (这个模型会不会 │
│ 原生工具调用?) │
└───────────┬─────────────┘
会 │ │ 不会 / 不确定
▼ ▼
create_react_agent CiaynAgent(model, tools, ...)
(interrupt_after= ┌──────────────────────────┐
["tools"]) │ 代码即工具调用 │
agent_utils.py:194 │ ciayn_agent.py:98 │
│ └──────────┬───────────────┘
└──────────┬───────────┘

返回一个可 .stream() 的 agent
类型 = RAgents (agents_alias.py:9)

返回类型是个联合体。 两条路吐出的对象类型不同,RA.Aid 用一个别名把它们统一:

RAgents = CompiledGraph | CiaynAgent(agents_alias.py:9)

上层编排(见 01 章)只认 RAgents,不关心底下到底是哪种后端——两者都提供 .stream()。这是本设计的关键解耦点:分叉只发生在 create_agent 内部,外面看不见。

部件一句话职责:

部件干什么在哪
create_agent分叉路口:选后端、装配 kwargsagent_utils.py:133
should_use_react_agent判据:这个模型该走哪条model_detection.py:117
build_agent_kwargs给 ReAct 装配 token 裁剪等 kwargsagent_utils.py:81
CiaynAgentCIAYN 后端本体ciayn_agent.py:98
RAgents统一两种后端的类型别名agents_alias.py:9

ReAct 分支的两个细节(agent_utils.py:194-196):

  • interrupt_after=["tools"]:每执行完一次工具就中断,把控制权交回主循环——这样 RA.Aid 能在每步之间插入自己的逻辑(检查退出标志、human-in-the-loop、成本追踪)。这是它没有让 LangGraph 一路跑到底的原因。
  • build_agent_kwargs 里塞进一个 version: "v2" 和一个 prompt(状态修改器),后者负责 ReAct 侧的 token 裁剪(agent_utils.py:96-125)。裁剪细节见 05 章,这里只需知道两条后端各有各的历史裁剪

3. 分叉判据:should_use_react_agent

这节讲那个菱形里到底怎么判断。核心函数 model_detection.py:should_use_react_agent(model_detection.py:117),两步走:

第一步:问 litellm "这模型支持工具调用吗"。

# 示意,非源码 —— 真实见 model_detection.py:131-138
supports = litellm.supports_function_calling( # litellm 内置的能力表
model=model_name, custom_llm_provider=provider
)
use_react_agent = supports # 支持 → 走 ReAct

litellm.supports_function_calling 是 litellm 维护的一张"哪个模型支持结构化工具调用"的能力表。支持就默认走 ReAct。

第二步:RA.Aid 自己的配置可以覆盖这个判断。 RA.Aid 在 models_params.py 里对具体模型标了 default_backend,一旦存在就压过 litellm 的检测结果(model_detection.py:151-156):

# 示意,非源码 —— 真实见 model_detection.py:151-156
if "default_backend" in model_config:
configured = model_config["default_backend"]
use_react_agent = (configured == AgentBackendType.CREATE_REACT_AGENT)

为什么要能覆盖? 因为"litellm 说支持"不等于"实际好用"。RA.Aid 踩过坑后,直接在配置里把某些模型钉死到某后端。后端枚举与全局默认(models_params.py:8-16):

常量含义
AgentBackendType.CREATE_REACT_AGENT1走 LangGraph ReAct
AgentBackendType.CIAYN2走 CIAYN
DEFAULT_AGENT_BACKENDCIAYN判不出来时的兜底(models_params.py:16)

注意这个耐人寻味的默认:全局兜底是 CIAYN,不是 ReAct。 也就是说 CIAYN 被当成"最大公约数"——代码即工具调用能覆盖最弱的模型,所以拿不准时宁可走它。(但 create_agent检测抛异常时的兜底又是 ReAct,见 agent_utils.py:208-225——两个"默认"服务于不同场景:配置查不到 → CIAYN;检测过程崩了 → ReAct。)


4. CIAYN 的哲学:代码即工具调用

从这里开始深入本章主角 CiaynAgent。它的类 docstring 把设计哲学讲得很直白(ciayn_agent.py:98-127):

  • 语言模型生成可执行的 Python 代码片段,而不是填结构化 schema;
  • 工具通过"自然的 Python 代码"调用,而非固定的 JSON;
  • 观察-推理-行动的 ReAct 循环照样跑,只是"行动"= 生成并执行一段代码;
  • 配套:自动生成工具文档、沙箱式执行、token 感知的历史管理、错误恢复。

一句话直觉: 别让弱模型学"工具菜单"这种它不会的东西;让它做它天生会的事——写一行代码。工具调用就此退化成"写代码 + 执行代码"。

下一节把这条从"模型吐字"到"结果回填"的完整链路拆开。


5. 一次 CIAYN 调用的生命周期

这是本章的核心。CiaynAgent.stream(ciayn_agent.py:929)是一个 ReAct 风格的 while 循环:建提示 → 问模型 → 校验 → 执行 → 回填 → 再来一轮。我们顺着数据流一站一站看。

怎么读:下面每个 5.x 就是循环里的一环,箭头代表数据流。

工具函数 ──(5.1 反射)──► 给模型看的"函数文档" ──┐

模型吐一段文本 ──(5.2 剥壳)──► 干净代码字符串

(5.3 AST 校验:是单个函数调用吗?)
│ 是
(5.4 拆多调用 / 防重复?)

(5.5 eval 在受限命名空间里执行)

结果 ──► 回填进 chat_history ──► 下一轮
│ 失败
(5.7 fallback 兜底)

5.1 工具怎么自动变成"给模型看的文档"

模型要写 read_file_tool(...),前提是它知道有这个函数、参数是什么。CIAYN 不手写文档,而是用反射从工具函数自身抽签名 + docstring

核心是 tools/reflection.py:get_function_info(reflection.py:13):

# 示意,非源码 —— 真实见 reflection.py:25-34
signature = inspect.signature(func) # 抽出 (path: str, ...) 这样的签名
docstring = inspect.getdoc(func) # 抽出函数文档
return f'{func.__name__}{signature}\n"""\n{docstring}\n"""'

CiaynAgent.__init__ 对每个工具跑一遍它,拼成 available_functions,再灌进系统提示(ciayn_agent.py:190-205):

# 示意,非源码 —— 真实见 ciayn_agent.py:190-205
for t in tools:
self.available_functions.append(get_function_info(t.func))
functions_list = "\n\n".join(self.available_functions)
self.sys_message = HumanMessage(
CIAYN_AGENT_SYSTEM_PROMPT.format(functions_list=functions_list)
)

两个值得记的细节:

  • 系统提示用 HumanMessage 装,不是 SystemMessage——注释点明原因:"因为不是所有模型都支持 SystemMessage"(ciayn_agent.py:202)。这本身就是"面向弱模型"的妥协。
  • CIAYN_AGENT_SYSTEM_PROMPT(ciayn_prompts.py:21)里反复用大写咆哮体强调 "永远只返回一行调用某个函数的 Python、别返回 markdown、别返回纯文本"——因为弱模型极容易跑偏,提示词只能靠反复强调把它拉回轨道。

5.2 剥掉 markdown 外壳:strip_code_markup

模型经常不听话,把代码包进 markdown 围栏里再吐出来。strip_code_markup(ciayn_agent.py:227)负责把这些外壳剥干净——处理带语言标识的围栏、不带语言的围栏、以及单反引号包裹。它要把下面这种输入:

```python
read_file_tool("a.py")
```

还原成裸的 read_file_tool("a.py")。逻辑就是识别开头的 ```/` 和结尾的围栏并切掉(ciayn_agent.py:242-281)。这一步是"容错",不是校验——它假设模型会犯"多包一层 markdown"的错,提前替它擦干净。

5.3 校验:这真的是"一个函数调用"吗

剥壳后的字符串不能直接 eval——万一模型吐的是 import os; os.system(...) 或者一段解释性文字怎么办?于是有了 validate_function_call_pattern(ciayn_agent.py:45)。它不是正则,而是ast.parse 真正解析一遍:

# 示意,非源码 —— 真实见 ciayn_agent.py:73-85
tree = ast.parse(s)
if (len(tree.body) == 1 # 只有一条语句
and isinstance(tree.body[0], ast.Expr) # 且是个表达式
and isinstance(tree.body[0].value, ast.Call)): # 且是函数调用
return False # 合法(注意:False = 合法)
return True # 其它一律判非法

重点看返回值的反直觉约定:False 才是"合法"。 函数名是"pattern 是否有问题",所以"是干净的单个函数调用"返回 False(没问题)。这个 AST 判据把"必须恰好是一个函数调用表达式"编码得非常干净——多条语句、赋值、import、裸文字,全被挡在外面。

校验失败怎么办? 分两种(ciayn_agent.py:546-583):

  • 模型配置里开了 attempt_llm_tool_extraction(models_params.py:83),就再问一次 LLM,让它把这段烂输出重新格式化成合法调用(用 EXTRACT_TOOL_CALL_PROMPT,ciayn_prompts.py:9);
  • 没开,就抛 ToolExecutionError,进入 5.7 的兜底流程。

5.4 打包与防重复:两个针对弱模型的护栏

打包(bundling)。 弱模型有时一口气吐好几个调用。CIAYN 不是一律拒绝,而是有条件地允许打包:_detect_multiple_tool_calls(ciayn_agent.py:283)用 AST 把多条表达式拆开,只有当每一个都在白名单 BUNDLEABLE_TOOLS(ciayn_agent.py:130-141,如 emit_key_factsread_file_toolrun_shell_command 等只读/幂等类工具),才逐个执行;只要有一个不在白名单,就整体退回当单条处理(ciayn_agent.py:317-326)。

为什么要白名单?因为把有副作用、有顺序依赖的工具打包执行是危险的。只有"读读读、记记记"这类互不干扰的调用才准打包,省往返。

防重复(no-repeat)。 弱模型的经典失败模式是卡在循环里反复调同一个工具、同样的参数。CIAYN 给 NO_REPEAT_TOOLS(ciayn_agent.py:146-157)里的工具做指纹去重:用 AST 抽出这次调用的工具名 + 规范化后的参数,拼成指纹,和上一次比(ciayn_agent.py:500655):

# 示意,非源码 —— 真实见 ciayn_agent.py:499-508 / 654-667
current_call = (tool_name, str(sorted(param_pairs))) # 归一化的调用指纹
if current_call == self.last_tool_call: # 和上次一模一样?
return f"Repeat calls of {tool_name} ... are not allowed. You must try something different!"
self.last_tool_call = current_call

命中就不执行,直接把"别再重复了,换个招"塞回给模型。参数归一化会剥掉字符串外层引号(ciayn_agent.py:462-470),这样 'a.py'"a.py" 算同一次调用,避免被表面差异骗过。

5.5 执行:在受限命名空间里 eval

校验通过,终于执行。关键在 _execute_tool(ciayn_agent.py:339),而"沙箱"其实是一个手工搭的命名空间:

# 示意,非源码 —— 真实见 ciayn_agent.py:348 与 691
globals_dict = {tool.func.__name__: tool.func for tool in self.tools} # 只放工具函数
...
result = eval(code.strip(), globals_dict) # 在这个命名空间里求值

妙点也是风险点: 因为 globals_dict 只塞了工具函数,模型写的 read_file_tool("a.py") 能找到 read_file_tool,但它没有 import os 之类的入口(而且 5.3 的 AST 校验已挡掉 import 语句)。这是一种"够用就好"的轻量隔离——不是真沙箱,但配合前面的 AST 白名单校验,把攻击面收窄到"只能调已注册的工具"。执行成功就返回结果;失败则 catch、记录 trajectory、抛 ToolExecutionError(ciayn_agent.py:707-763)。

5.6 主循环与历史裁剪

把上面各环节串起来的是 stream(ciayn_agent.py:929)。它的骨架:

while True:
检查退出标志 → 若退出则 break
full_history = _trim_chat_history(initial_messages, chat_history) # 裁历史
response = model.invoke([sys_message] + full_history) # 问模型
处理 thinking 内容 / 空响应重试(最多 3 次,否则 mark_agent_crashed)
last_result = _execute_tool(response) # 校验 + 执行(5.2~5.5)
chat_history.append(response); yield {} # 回填,交回控制权

两个值得记的点:

  • 空响应有专门的重试上限。 弱模型可能干脆返回空串;连续 3 次(max_empty_responses)就判 agent 崩溃(ciayn_agent.py:1042-1094)。
  • 历史裁剪是 CIAYN 自己做的。 _trim_chat_history(ciayn_agent.py:872)先按条数上限截,再按 token 上限从最旧的消息往外弹,但始终保留 initial_messages(系统提示等)。token 数由 _estimate_tokens(ciayn_agent.py:909)粗估——直接 len(utf8 字节) // 2,不调 tokenizer,图快。裁剪与 token 上限的具体策略在 05 章展开。

5.7 失败兜底:handle_fallback_response

_execute_toolToolExecutionError,stream 交给 FallbackHandler,再由 handle_fallback_response(ciayn_agent.py:784)把结果翻译回对话:

  • fallback 成功:往历史里塞一条"兜底处理器已修好这次调用,见 <fallback tool call result>",让模型继续(ciayn_agent.py:844-855);
  • fallback 失败 / 未启用:塞回原始错误信息,让模型看到并自己纠正(ciayn_agent.py:790-842,以及 stream1114-1130 的收尾)。

模型回退到哪个备用模型、连续失败几次触发,属于健壮性主题,见 05 章


6. 巧妙之处(可借鉴的技术)

  • 用 AST 而非正则做工具调用校验。 validate_function_call_pattern(ciayn_agent.py:45)靠 ast.parse + "恰好一个 Expr/Call"判据,天然挡掉多语句、赋值、import、裸文字——比正则精确得多,还顺手收窄了 eval 的攻击面。
  • 返回值 False = 合法 的反直觉但自洽的约定。 函数语义是"pattern 是否有问题",读代码时记住这点就不会看反。
  • 有条件打包 + 指纹防重,是两条专治弱模型的护栏。 白名单(BUNDLEABLE_TOOLS,ciayn_agent.py:130)只让幂等类工具打包;指纹去重(current_call,ciayn_agent.py:500)把"卡死循环"变成一句"换个招"。
  • 命名空间即沙箱。 globals_dict = {func.__name__: func}(ciayn_agent.py:348)——不引入真正的沙箱依赖,靠"只暴露工具函数"+ AST 校验做轻量隔离。
  • 系统提示用 HumanMessage(ciayn_agent.py:202),就为了兼容不支持 SystemMessage 的模型——处处透着"迁就最弱模型"的取向。

7. 边界与局限

  • CIAYN 的隔离不是真沙箱。 它挡住了 import 和非函数调用,但工具函数本身能做什么(读写文件、跑 shell)完全取决于工具实现;eval 一旦执行,边界就交给工具了。
  • _estimate_tokens 是粗估(字节数 ÷ 2,ciayn_agent.py:909),不等于真实 tokenizer 计数,极端情况下可能裁多或裁少。
  • attempt_llm_tool_extraction 开启时会调用 self._extract_tool_call(ciayn_agent.py:417566)——该方法在本文件中未见定义 (inferred:可能定义在他处或依赖运行期注入);只有配置里开了该开关的模型才会走到这条路,默认关闭。
  • 强弱之分靠 litellm 能力表 + 手工配置(model_detection.py:132models_params.py),表没覆盖到的新模型会落到默认后端(CIAYN),未必最优——所以 RA.Aid 才留了 default_backend 覆盖口子。

8. 代码地图(导航索引)

主题文件路径符号名
后端分叉入口ra_aid/agent_utils.py:133create_agent
ReAct kwargs 装配ra_aid/agent_utils.py:81build_agent_kwargs
选后端判据ra_aid/model_detection.py:117should_use_react_agent
后端枚举 / 默认ra_aid/models_params.py:8AgentBackendType · DEFAULT_AGENT_BACKEND
后端统一类型ra_aid/agents_alias.py:9RAgents
CIAYN 本体 / 哲学ra_aid/agent_backends/ciayn_agent.py:98CiaynAgent
工具→文档反射ra_aid/tools/reflection.py:13get_function_info
系统提示模板ra_aid/prompts/ciayn_prompts.py:21CIAYN_AGENT_SYSTEM_PROMPT
剥 markdown 外壳ra_aid/agent_backends/ciayn_agent.py:227strip_code_markup
AST 校验单调用ra_aid/agent_backends/ciayn_agent.py:45validate_function_call_pattern
拆多调用 / 打包ra_aid/agent_backends/ciayn_agent.py:283_detect_multiple_tool_calls · BUNDLEABLE_TOOLS
防重复指纹ra_aid/agent_backends/ciayn_agent.py:146NO_REPEAT_TOOLS · last_tool_call
受限命名空间执行ra_aid/agent_backends/ciayn_agent.py:339_execute_tool(eval @691)
ReAct 风格主循环ra_aid/agent_backends/ciayn_agent.py:929stream
历史裁剪 / token 估算ra_aid/agent_backends/ciayn_agent.py:872_trim_chat_history · _estimate_tokens
失败兜底ra_aid/agent_backends/ciayn_agent.py:784handle_fallback_response

相邻章节: index · 01 三阶段编排 · 03 记忆与持久化 · 04 工具箱与专家/HIL · 05 健壮性与成本