两种 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 不愿意——它想让同一套工具在弱模型上也能跑。于是有了两条后端:
| 后端 | 实现 | 适用模型 | 工具怎么"调" |
|---|---|---|---|
| ReAct | LangGraph 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)。它是那个分叉路口:拿到 model 和 tools,判断该走哪条后端,返回一个能 .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 | 分叉路口:选后端、装配 kwargs | agent_utils.py:133 |
should_use_react_agent | 判据:这个模型该走哪条 | model_detection.py:117 |
build_agent_kwargs | 给 ReAct 装配 token 裁剪等 kwargs | agent_utils.py:81 |
CiaynAgent | CIAYN 后端本体 | 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_AGENT | 1 | 走 LangGraph ReAct |
AgentBackendType.CIAYN | 2 | 走 CIAYN |
DEFAULT_AGENT_BACKEND | CIAYN | 判不出来时的兜底(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_facts、read_file_tool、run_shell_command 等只读/幂等类工具),才逐个执行;只要有一个不在白名单,就整体退回当单条处理(ciayn_agent.py:317-326)。
为什么要白名单?因为把有副作用、有顺序依赖的工具打包执行是危险的。只有"读读读、记记记"这类互不干扰的调用才准打包,省往返。
防重复(no-repeat)。 弱模型的经典失败模式是卡在循环里反复调同一个工具、同样的参数。CIAYN 给 NO_REPEAT_TOOLS(ciayn_agent.py:146-157)里的工具做指纹去重:用 AST 抽出这次调用的工具名 + 规范化后的参数,拼成指纹,和上一次比(ciayn_agent.py:500、655):
# 示意,非源码 —— 真实见 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_tool 抛 ToolExecutionError,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,以及stream中1114-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:417、566)——该方法在本文件中未见定义(inferred:可能定义在他处或依赖运行期注入);只有配置里开了该开关的模型才会走到这条路,默认关闭。- 强弱之分靠 litellm 能力表 + 手工配置(
model_detection.py:132、models_params.py),表没覆盖到的新模型会落到默认后端(CIAYN),未必最优——所以 RA.Aid 才留了default_backend覆盖口子。
8. 代码地图(导航索引)
| 主题 | 文件路径 | 符号名 |
|---|---|---|
| 后端分叉入口 | ra_aid/agent_utils.py:133 | create_agent |
| ReAct kwargs 装配 | ra_aid/agent_utils.py:81 | build_agent_kwargs |
| 选后端判据 | ra_aid/model_detection.py:117 | should_use_react_agent |
| 后端枚举 / 默认 | ra_aid/models_params.py:8 | AgentBackendType · DEFAULT_AGENT_BACKEND |
| 后端统一类型 | ra_aid/agents_alias.py:9 | RAgents |
| CIAYN 本体 / 哲学 | ra_aid/agent_backends/ciayn_agent.py:98 | CiaynAgent |
| 工具→文档反射 | ra_aid/tools/reflection.py:13 | get_function_info |
| 系统提示模板 | ra_aid/prompts/ciayn_prompts.py:21 | CIAYN_AGENT_SYSTEM_PROMPT |
| 剥 markdown 外壳 | ra_aid/agent_backends/ciayn_agent.py:227 | strip_code_markup |
| AST 校验单调用 | ra_aid/agent_backends/ciayn_agent.py:45 | validate_function_call_pattern |
| 拆多调用 / 打包 | ra_aid/agent_backends/ciayn_agent.py:283 | _detect_multiple_tool_calls · BUNDLEABLE_TOOLS |
| 防重复指纹 | ra_aid/agent_backends/ciayn_agent.py:146 | NO_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:929 | stream |
| 历史裁剪 / token 估算 | ra_aid/agent_backends/ciayn_agent.py:872 | _trim_chat_history · _estimate_tokens |
| 失败兜底 | ra_aid/agent_backends/ciayn_agent.py:784 | handle_fallback_response |
相邻章节: index · 01 三阶段编排 · 03 记忆与持久化 · 04 工具箱与专家/HIL · 05 健壮性与成本