跳到主要内容

第 1 章:五标签 agentic 循环

本章讲 DeepAnalyze 的心脏:一次任务从请求到报告,外层那薄薄一层代码是怎么转的。读 deepanalyze.py 这一个文件就够——它是参考实现,API/RL 版本只是它的「加固版」。

1.1 它要解决的小问题

LLM 本身只会「生成文本」,不会真的运行代码、也看不到运行结果。数据分析偏偏离不开「跑一下看看」。所以需要一个外层驱动,在模型说「我要跑这段代码」时,真的去跑,再把结果喂回去让它接着想。DeepAnalyze 的整个 agent 能力,外层就负责这一件事。

1.2 思路 / 直觉:把执行点变成「停车标志」

关键技巧:让模型生成到 </Code> 就强制停下

请求 vLLM 时设了 stop=["</Code>"]——模型一写完一段代码的收尾标签,生成立刻中断。这样外层就拿到一个干净的「半句话」:前面是 <Analyze> + <Code>...,正好可以抽出代码去执行,而不会让模型自己幻想出一段 <Execute> 假结果。

模型生成: <Analyze>先看看数据</Analyze><Code>```python\nimport pandas...\n```</Code>

stop 在这里截断,控制权交回驱动

执行完,把真实结果包成 <Execute>...</Execute> 拼进 messages,再发下一轮——模型这才「看到」结果。真结果回灌,而不是让模型自说自话,是这套循环可靠的根本。

1.3 主循环走读

核心在 DeepAnalyzeVLLM.generate(deepanalyze.py:68-147)。先看骨架:

# 示意,提炼自 deepanalyze.py:generate
messages = [{"role": "user", "content": prompt}]
for round_idx in range(self.max_rounds): # 默认 30 轮
payload = {..., "stop": ["</Code>"], "add_generation_prompt": False}
ans = call_vllm(payload) # 生成一段带标签文本
if stop_reason == "</Code>": ans += "</Code>" # 截断时补回收尾标签

if "<Answer>" in ans: break # 出现答案 → 收工

code_match = re.search(r"<Code>(.*?)</Code>", ans, re.DOTALL)
if not code_match: # 只有 <Analyze> 没代码
messages.append({"role": "assistant", "content": ans})
continue # 让它下一轮再写代码

code_str = extract_python(code_match.group(1)) # 抽出 ```python ... ``` 里的真码
exe_output = self.execute_code(code_str) # 真的跑
messages.append({"role": "assistant", "content": ans})
messages.append({"role": "execute", "content": exe_output}) # 回灌结果

几个值得停下来看的点:

(a) 退出条件只认 <Answer> deepanalyze.py:118 一句 if "<Answer>" in ans: break。模型不写答案,循环就不停(直到 30 轮上限)。所以「什么时候算分析完」完全由模型自己判断。

(b) 中间步骤会「空转一轮」。 如果模型这一段只有 <Analyze> 没有 <Code>(deepanalyze.py:123-127),驱动不报错,只是把它当 assistant 消息存下、continue,给模型机会下一轮再补代码。这让模型可以「先想清楚、下一轮再动手」。

(c) 代码要先剥两层。 抽代码是两步正则(deepanalyze.py:122-131):先 <Code>(.*?)</Code> 拿到内容,再 ```(?:python)?(.*?)``` 把 Markdown 围栏里的真码挖出来。模型被训成把代码放进 ```python ... ```,所以要剥两层。

1.4 执行那一步:最朴素的 exec

参考实现的执行极其朴素(deepanalyze.py:26-66):

# 示意,提炼自 deepanalyze.py:execute_code
with contextlib.redirect_stdout(out), contextlib.redirect_stderr(err):
exec(code_str, {}) # 直接在本进程 exec,空 globals
output = out.getvalue() + err.getvalue()

报错时它会把 traceback 里 File "<string>", line N 的行号抠出来,定位到出错那一行源码,拼一条更友好的错误信息回灌(deepanalyze.py:43-66)。这点很重要:模型靠这条 [Error]: ... 学会「上一步崩了,我得改」。

注意工作目录处理:generate 一进来就 os.chdir(workspace)(deepanalyze.py:81-82),让模型写的 pd.read_csv("person.csv") 这种相对路径能命中数据文件;结束再 chdir 回去。

1.5 关键细节 / 坑

参考实现没有沙箱。 exec(code_str, {}) 在主进程里直接跑模型生成的任意代码——研究脚本图省事,但生产绝不能这么用。这正是后面几版驱动要解决的:

版本执行方式隔离出处
deepanalyze.py本进程 execdeepanalyze.py:38
RL python_tool子进程 multiprocessing.Process + 超时杀进程进程级examples/deepanalyze/python_tool.py:118-136
API临时 .py 文件 + 子进程进程级API/utils.py:execute_code_safe
Web v2每会话一个 Docker 容器容器级docker_executor.py:execute_python_in_docker

role: "execute" 是个非标准角色。 回灌结果时用的是 {"role": "execute", ...}(deepanalyze.py:139),不是标准的 user/assistant/tool。模型在训练时就见过这个角色,所以认得。换别的模型直接喂这个角色多半会出问题——这又指向「协议焊在权重里」(详见 02 章)。

异常吞掉、返回已生成部分。 整个 generate 包在 try/except 里(deepanalyze.py:143-144),任何崩溃都只是 reasoning = "\n".join(response_message) 返回到目前为止的过程,不抛出。研究脚本风格,容错优先。

1.6 代码地图

主题文件符号
主循环deepanalyze.pyDeepAnalyzeVLLM.generate
代码执行 + 错误定位deepanalyze.pyDeepAnalyzeVLLM.execute_code
截断技巧deepanalyze.pypayload["stop"] = ["</Code>"]
子进程执行版deepanalyze/SkyRL/skyrl-train/examples/deepanalyze/python_tool.pyPythonCodeExecutorToolGroup.python_code_execution_target