Agent 主循环:一问一答怎么跑完
30 秒导读: 你问 PandasAI 一句自然语言(比如"哪个国家 GDP 最高?"),它内部不是直接答你, 而是先让 LLM 写一段 Python 代码,再执行这段代码,把代码的运行结果当答案返回。这一章讲的就是 从"收到问题"到"吐出结果"这条端到端主线的控制流:两个阶段(生成、执行)、每个阶段各自的重试、 以及贯穿全程的上下文对象
AgentState。
本章只讲主线控制流。代码怎么被校验/清洗/安全执行,见 代码即 SQL; 喂给 LLM 的 prompt 长什么样,见 Prompt 工程;数据集/视图/SQL 编译,见 语义层。
1. 先建立直觉:它把"回答问题"变成"写代码 + 跑代码"
传统聊天机器人:你问一句,模型直接生成一段文字答案。PandasAI 不一样——它多绕了一道:
- 它不让模型直接回答,而是让模型写一段操作数据的 Python 代码。
- 然后在本地执行这段代码,代码算出来的东西(一个数字、一张表、一张图)才是最终答案。
为什么要绕这道?因为模型擅长写代码、不擅长算数。让它对着几百万行数据"心算"必然出错;
但让它写一句 SELECT country ORDER BY gdp DESC LIMIT 1,交给真正的引擎去跑,结果就精确可信。
一句类比:你不是问会计"总和是多少",而是让会计写好公式、按下计算器。模型负责"写公式", 引擎负责"按计算器"。
最小使用示例(帮你感受输入输出形态):
import pandasai as pai
df = pai.DataFrame("data.csv")
# 一次 chat:内部会生成代码 + 执行代码,返回的是"执行结果"而不是"模型原话"
result = df.chat("哪个国家的 GDP 最高?")
# 追问:复用上一轮的记忆和上一段代码,做增量修改
result = df.follow_up("那前三名呢?")
chat= 开一段新对话(先清空记忆),问第一句。follow_up= 在已有对话上追问,带着上下文继续。
两者的区别只有一处:chat 会先清记忆,follow_up 不会。真正干活的是它们共同调用的 _process_query。
真实入口在 pandasai/agent/base.py:chat(:92)、follow_up(:105)。
chat 里先做了个前置检查——没有 LLM credits 直接报错(:96),因为"没有模型就没法写代码"。
2. 顶层全景:两阶段流水线
整条主线可以浓缩成一句话:生成代码(带重试)→ 执行代码(带重试)。
┌─────────────────────── _process_query (base.py:271) ───────────────────────┐
│ │
用户问题 ──▶ │ ① generate_code_with_retries ② execute_with_retries │ ──▶ 结果
│ "让 LLM 写代码" "跑代码 + 解析结果" │
│ │ 失败? │ 失败? │
│ ▼ 把 traceback 喂回 LLM ▼ 把 traceback 喂回 LLM │
│ _regenerate_code_after_error ◀────────┘ (两阶段共用这个改错函数) │
└─────────────────────────────────────────────────────────────────────────────┘
贯穿全程:AgentState(记忆 memory + last_code_generated + config/llm)
怎么读这张图: 从左到右是主线顺序;两个阶段都可能失败,失败时都掉进同一个"把报错喂回 LLM 让它改"的
函数 _regenerate_code_after_error;底部那行 AgentState 是从头贯穿到尾的上下文与记忆。
各部件一句话职责:
| 部件 | 干什么 | 在哪里 |
|---|---|---|
chat / follow_up | 对外入口;chat 先清记忆再问,follow_up 直接续问 | agent/base.py:92 / :105 |
_process_query | 主线编排:调生成、调执行、兜底异常 | agent/base.py:271 |
generate_code_with_retries | 生成阶段:调 LLM 写代码,失败按重试改 | agent/base.py:171 |
execute_with_retries | 执 行阶段:跑代码 + 解析结果,失败按重试改 | agent/base.py:197 |
_regenerate_code_after_error | 两阶段共用的"改错":把 traceback 喂回 LLM | agent/base.py:296 |
_handle_exception | 兜底:重试用光后返回 ErrorResponse | agent/base.py:310 |
CodeGenerator.generate_code | "生成→校验→清洗"三步,产出可执行代码 | core/code_generation/base.py:16 |
AgentState | 贯穿上下文:记忆、上一段代码、中间值、config/llm | agent/state.py:24 |
主线走一遍(高层,不进代码): 用户问题进来 → _process_query 先给这轮分配一个 prompt_id →
调 ① 生成代码(内部让 LLM 写、校验、清洗)→ 调 ② 执行代码(跑出结果、解析成响应)→ 返回结果。
中途任何阶段崩了,先在本阶段内重试改错;重试用光了才向上抛,由 _handle_exception 兜成一个错误响应。
3. AgentState:贯穿全程的"上下文与记忆"
在讲两个阶段之前,得先认识那个从头贯穿到尾的对象——AgentState(agent/state.py:24)。
它是一个 @dataclass,注释直说自己是"在流水线各步之间传递属性的上下文类"。你可以把它理解为
这一整段对话的共享白板:谁都能往上写、往上读。
白板上最关键的几格:
| 字段 | 装什么 | 谁在用 |
|---|---|---|
memory | 多轮对话历史(问/答交替) | 生成 prompt 时取上下文;chat 时被清空 |
last_code_generated | 上一次生成并清洗后的代码 | 决定"给初始模板还是给上一段代码";改错时作为基底 |
intermediate_values | 中间产物的键值缓存 | 执行期存取(add/get,:98/:106) |
output_type | 本轮期望的返回类型 | 生成 prompt 与校验时约束 |
last_prompt_id | 本轮的唯一 id | 日志追踪(assign_prompt_id,:87) |
config 与 llm 的解析是 AgentState 一个容易被忽略的巧处。config 是一个 property(:110):
- 如果本地设了
_config,就用本地的;否则回退到全局pai.config.get()(:118)。 - 初始化时
_get_config(:73)负责把 dict 转成Config对象,或在没传时取全局默认。
LLM 就挂在 config.llm 上——所以 _process_query 一进来就能 self._state.config.llm.type(base.py:276)
拿到当前模型;真正写代码那一步是 config.llm.generate_code(...)(code_generation/base.py:33)。
这就是"config/llm 解析"如何把主循环和背后的模型接起来。
Agent 在构造时初始化这块白板:self._state.initialize(dfs, config, memory_size, vectorstore, description)
(base.py:83,展开见 state.py:45)。
4. 阶段①:生成代码(带重试)
这一节讲 generate_code_with_retries(base.py:171)——"让 LLM 把代码写出来,写不出来就按报错重写"。
4.1 一次干净的生成:memory 先记账,再拼 prompt
单次生成走 generate_code(base.py:111),三个动作:
# 真实逻辑(base.py:111),精简展示
self._state.memory.add(str(query), is_user=True) # ① 把用户问题记进记忆
prompt = get_chat_prompt_for_sql(self._state) # ② 用当前 state 拼出 prompt
code = self._code_generator.generate_code(prompt) # ③ 交给 CodeGenerator 产出代码
注意顺序:先把 query 写进 memory,再拼 prompt。这一步顺序很关键,下面 §4.3 会解释它如何影响 "给初始模板还是给上一段代码"。
4.2 CodeGenerator 的"生成→校验→清洗"三步
generate_code(prompt) 拿到 prompt 后,真正干活的是 CodeGenerator.generate_code
(core/code_generation/base.py:16)。它把"从 prompt 到可用代码"拆成三步:
prompt
│
▼
① 生成 config.llm.generate_code(prompt, ctx) → 原始代码,存进 last_code_generated
│
▼
② 校验 CodeRequirementValidator.validate(code) → 不满足要求就 raise ValueError
│
▼
③ 清洗 CodeCleaner.clean_code(code) → 清洗后的代码,再次覆盖 last_code_generated
│
▼
返回清洗后的代码
对应源码:生成在 :33,校验在 validate_and_clean_code(:54)里先 validate(:57),
清洗 clean_code(:63)。一个细节:last_code_generated 被写了两次——
先存原始生成(:35,便于日志),清洗后再覆盖成最终版(:41,便于后续多轮复用)。
校验和清洗的内部规则不在本章,见 代码即 SQL。
4.3 memory 如何驱动"初始模板 vs 上一段代码"
这是主循环里最精妙的一处:同一个 prompt 模板,第一轮和后续轮喂给 LLM 的"起点"不一样。
判断逻辑写在模板里(core/prompts/templates/generate_python_code_with_sql.tmpl:9):
{% if last_code_generated and context.memory.count() > 0 %}
Last code generated:
{{ last_code_generated }}
{% else %}
Update this initial code:
```python
# TODO: import the required dependencies
import pandas as pd
# Write code here
# Declare result var: ...
```
{% endif %}
拆开看这个条件:
context.memory.count() > 0:几乎总为真。因为 §4.1 里generate_code会先memory.add再拼 prompt,所以拼 prompt 时记忆里至少已有当前这条 query。- 真正的开关是
last_code_generated有没有值:
| 轮次 | last_code_generated | LLM 收到的起点 |
|---|---|---|
首轮(chat 后第一问) | None(初始) | "Update this initial code" + 一段空白代码模板 |
后续(follow_up / 重试) | 上一轮清洗后的代码 | "Last code generated:" + 上一段真实代码 |
直觉:第一次没有历史代码可改,就给一张"填空模板"让 LLM 从零写起;之后有了上一段代码,就把它交回去,
让 LLM 在既有代码上做增量修改——这也是 follow_up 能"接着上文改"的底层原因。
这条 prompt = get_chat_prompt_for_sql(self._state)(base.py:117)里,
get_chat_prompt_for_sql 正是把 context.last_code_generated 传进模板的(core/prompts/__init__.py:19)。
4.4 生成阶段的重试语义
回到带重试的外层 generate_code_with_retries(base.py:171):
# 精简自 base.py:171
max_retries = self._state.config.max_retries # 默认 3(config.py:13)
try:
return self.generate_code(query) # 先正常生成一次
except Exception as e:
exception = e
while attempts <= max_retries:
try:
return self._regenerate_code_after_error( # 带着上段代码 + 报错重新生成
self._state.last_code_generated, exception)
except Exception as e:
exception = e
attempts += 1
if attempts > max_retries:
raise # 用光重试,向上抛
要点:
- 首次生成不算重试;失败后最多再试
max_retries次(默认 3,config.py:13)。 - 每次重试都调
_regenerate_code_after_error,把last_code_generated+ 异常一起喂回 LLM。 - 用光仍失败就
raise,冒泡到_process_query。