Agent:让模型自己"想一步、做一步、再想一步"
Day16 我们造好了一柜子工具,但工具不会自己动。Agent 就是那个"大脑":拿到你的问题后,它自己判断"要不要用工具、用哪个、传什么参数",拿到结果再判断"够不够回答、要不要再来一轮"。今天进 core/agent/,弄清三件事:①Dify 的两种 Agent 策略 CoT 与 Function Calling 分别是什么、区别在哪;②它们共享的底座 BaseAgentRunner 做了什么;③两种策略各自的"循环"是怎么转起来、又怎么停下来的。
痛点:工具有了,谁来指挥
AgentRunner,并提供两种"让模型表达行动"的方式:CoT(用文本约定)和 Function Calling(用结构化字段)。两种策略:CoT 与 Function Calling
Dify 在 core/agent/entities.py 里把策略定成一个枚举(core/agent/entities.py:73):
# api/core/agent/entities.py:68
class AgentEntity(BaseModel):
class Strategy(StrEnum):
CHAIN_OF_THOUGHT = "chain-of-thought" # ← CoT:靠提示词约定的文本格式
FUNCTION_CALLING = "function-calling" # ← FC:靠模型原生的工具调用能力
provider: str
model: str
strategy: Strategy # 用哪种策略
prompt: AgentPromptEntity | None = None
tools: list[AgentToolEntity] | None = None # 这个 Agent 能用哪些工具
max_iteration: int = 10 # ★最多循环几轮(防跑飞)
| 维度 | CoT(思维链) | Function Calling |
|---|---|---|
| 怎么表达"要调工具" | 模型输出一段约定格式的文本(Thought/Action/Action Input) | 模型返回结构化的 tool_calls 字段 |
| 对模型要求 | 几乎所有模型都能用 | 模型必须原生支持 function calling |
| 可靠性 | 要解析文本,偶尔格式跑偏 | 结构化,更稳 |
| Runner 类 | CotAgentRunner cot_agent_runner.py:40 | FunctionCallAgentRunner fc_agent_runner.py:36 |
BaseAgentRunner:两种策略的共同底座
两种 Runner 都继承 BaseAgentRunner(core/agent/base_agent_runner.py:51)。底座把"和策略无关的公共活"都干了。构造函数(base_agent_runner.py:52)接收一堆运行环境:
# api/core/agent/base_agent_runner.py:52
def __init__(self, *, session, tenant_id,
application_generate_entity: AgentChatAppGenerateEntity,
conversation: Conversation,
app_config: AgentChatAppConfig,
model_config: ModelConfigWithCredentialsEntity, ...):
... # 存下 租户/会话/应用配置/模型配置,供两种策略共用
最关键的公共方法是"把工具翻译给模型看"——_init_prompt_tools(base_agent_runner.py:188)和它内部的 _convert_tool_to_prompt_message_tool(base_agent_runner.py:138):
_init_prompt_tools返回两样东西:tool_instances(工具名 → Day16 那个可 invoke 的工具对象)和 prompt_messages_tools(每个工具的"描述",给模型看的菜单)。两种策略开场都调它。_convert_tool_to_prompt_message_tool把一个工具的定义(名字、说明、参数)转成模型能理解的 PromptMessageTool——就是把 Day16 说的"工具描述"真正拼出来。这是"工具"和"模型"之间的翻译官。create_agent_thought / save_agent_thought(base_agent_runner.py:223 / 265)每一轮"想了什么、调了什么工具、观察到什么"都存成一条 AgentThought 记录。你在 Dify 界面看到的 Agent 思考过程,就是它存的。BaseAgentRunner,两种策略就只需各写自己的 run()——"变化的部分"和"不变的部分"分离,正是模板方法/继承复用的经典用法,和 Day16 的 Tool 基类一个道理。CoT:思考-行动-观察的循环
看 CotAgentRunner.run(core/agent/cot_agent_runner.py:49)开头,两个细节最能说明 CoT 的精髓:
# api/core/agent/cot_agent_runner.py:49
def run(self, session, message, query, inputs):
...
# check model mode
if "Observation" not in app_generate_entity.model_conf.stop: # ★① 把 "Observation" 设成停止词
if ...provider not in self._ignore_observation_providers:
app_generate_entity.model_conf.stop.append("Observation")
iteration_step = 1
max_iteration_steps = min(app_config.agent.max_iteration, 99) + 1 # ★② 最多循环几轮(封顶 99)
tool_instances, prompt_messages_tools = self._init_prompt_tools() # 准备工具菜单
function_call_state = True
...
而模型输出的"行动"被解析成一个 Action(cot_agent_runner.py:36),循环里据它决定:是"最终答案"就结束,否则调工具(cot_agent_runner.py:227 附近):
# api/core/agent/cot_agent_runner.py (循环体节选)
if scratchpad.action.action_name.lower() == "final answer":
... # ★ 模型说"最终答案" → 收尾返回
else:
function_call_state = True # ★ 否则是要调工具 → 继续循环
tool_invoke_response, tool_invoke_meta = self._handle_invoke_action(
session=session, action=scratchpad.action,
tool_instances=tool_instances, ...)
scratchpad.observation = tool_invoke_response # 把工具结果记成"观察"
self.save_agent_thought(...) # 存这一轮的思考+行动+观察
把 "Observation" 设成停止词★神来之笔。CoT 让模型按 Thought/Action/Action Input/Observation 格式输出。但"观察"必须由系统真的调工具才能填!所以让模型一写到 Observation 就停下——系统接手去调工具、填真观察,再让模型接着想。否则模型会自己"幻觉"一个观察结果。scratchpad(草稿本)每轮的 thought/action/observation 都记在一个 AgentScratchpadUnit 里,串起来就是完整的推理轨迹。这也是喂给下一轮的"历史"。final answer 判断模型的行动名若是 "final answer",说明它觉得能回答了 → 结束循环、返回答案。否则 function_call_state=True,调完工具继续下一轮。_handle_invoke_action内部就是 Day16 的 ToolEngine——CoT 和工具系统在这里接头。Function Calling:让模型直接吐"工具调用"
FunctionCallAgentRunner.run(core/agent/fc_agent_runner.py:37)不用约定文本格式,而是读模型返回里结构化的 tool_calls。它的主循环骨架(fc_agent_runner.py:81):
# api/core/agent/fc_agent_runner.py:55
iteration_step = 1
max_iteration_steps = min(app_config.agent.max_iteration, 99) + 1
function_call_state = True
while function_call_state and iteration_step <= max_iteration_steps: # ★ 循环,带上限
function_call_state = False
if iteration_step == max_iteration_steps:
prompt_messages_tools = [] # 最后一轮:把工具收走,逼模型直接给答案
...
if self.check_tool_calls(chunk): # ★ 模型这轮吐了 tool_calls 吗?
function_call_state = True # 吐了 → 还要再循环
tool_calls.extend(self.extract_tool_calls(chunk) or []) # 抽出(id, 名字, 参数)
抽出工具调用后,逐个真的执行(fc_agent_runner.py:234)——注意它直接接上了 Day16 的 ToolEngine.agent_invoke:
# api/core/agent/fc_agent_runner.py:234
for tool_call_id, tool_call_name, tool_call_args in tool_calls:
tool_instance = tool_instances.get(tool_call_name)
if not tool_instance:
tool_response = {... "tool_response": f"there is not a tool named {tool_call_name}"} # 模型点了不存在的工具
else:
tool_invoke_response, message_files, tool_invoke_meta = ToolEngine.agent_invoke( # ★ 真调工具
session=session, tool=tool_instance, tool_parameters=tool_call_args,
user_id=self.user_id, tenant_id=self.tenant_id, message=self.message, ...)
tool_responses.append(tool_response) # 结果收集起来,喂回模型继续下一轮
check_tool_calls(fc_agent_runner.py:328)检查模型这轮流式输出里有没有 tool_calls。有就说明模型想调工具,function_call_state=True 继续循环;没有就是给了最终答案,退出。工具不存在的兜底模型偶尔会"点"一个不存在的工具。代码不崩溃,而是把 "there is not a tool named xxx" 当结果喂回去——让模型自己意识到并改口。这叫"优雅降级"。ToolEngine.agent_invoke★和 CoT 一样,最终都汇到 Day16 的工具引擎。两种策略只是"怎么拿到工具名和参数"不同,真正执行工具的路是同一条。多工具并行意图模型一轮可能吐出多个 tool_calls,代码用 for 逐个调、结果都收集起来一起喂回。迭代上限:防止 Agent 无限循环烧钱
两种 Runner 开头都有同一句 max_iteration_steps = min(app_config.agent.max_iteration, 99) + 1。这是 Agent 最重要的"保险丝"。FC 版还在到顶时直接报错(fc_agent_runner.py:229):
# api/core/agent/fc_agent_runner.py:228 附近
# Check if max iteration is reached and model still wants to call tools
if iteration_step == max_iteration_steps and tool_calls:
raise AgentMaxIterationError(app_config.agent.max_iteration) # ★ 到顶还想调工具 → 报错止损
max_iteration,一个跑飞的 Agent 能在几分钟内烧掉大量 token 费用。所以 Dify 硬性封顶(最多 99 轮),到顶还没答案就诚实报错而不是继续烧。你配置 Agent 时那个"最大迭代次数"就是这里——设太小任务做不完,设太大有失控风险,需要按任务复杂度权衡。串起来 + 今日小结
weather/send_msg 两个工具菜单发给模型 → 模型返回 tool_calls=[{name:"weather", args:{city:"上海"}}] → check_tool_calls 命中 → ToolEngine.agent_invoke(weather, {city:"上海"}) → 观察结果 {"temp":31} 喂回。第 2 轮:模型看到 31>30 → 返回 tool_calls=[{name:"send_msg", args:{text:"今天上海31度,记得带伞"}}] → 调发消息工具 → 成功。第 3 轮:模型无 tool_calls,直接答"已提醒您带伞" → function_call_state=False 退出。3 轮循环,<10 上限,正常收尾。👶 小白:Agent 和 Day04 那种普通对话应用,本质区别到底是什么?
👨🏫 老师:普通对话是"一次调模型、出答案就结束",是一条直线。Agent 是"一个 while 循环里反复调模型 + 调工具",是一个圈,转到满意为止。所以 Agent 会更慢、更贵(要调好几次模型),但能完成"需要中间步骤、需要外部信息"的复杂任务。选普通还是 Agent,取决于任务要不要"边做边查"。
🧠 今天你应该能回答
- Agent 的本质是什么?(一个带工具的 while 循环:想→做→看→再想)
- CoT 和 FC 的核心区别?(文本约定 vs 模型原生结构化 tool_calls)
- 为什么 CoT 要把 "Observation" 设成停止词?(防止模型自己幻觉观察结果)
BaseAgentRunner干了什么?(准备工具菜单、存思考、管历史等公共活)- 两种策略最后都汇到哪里执行工具?(Day16 的
ToolEngine) max_iteration为什么重要?(保险丝:防死循环烧钱,封顶 99)
✋ 10 分钟动手
cd /Users/bitmart/work/codes/github/AI_WORK/dify
# 1. 两种策略枚举 + 迭代上限
sed -n '68,87p' api/core/agent/entities.py # Strategy / max_iteration
# 2. 共同底座
sed -n '52,60p' api/core/agent/base_agent_runner.py # __init__
grep -n "def _init_prompt_tools\|def _convert_tool_to_prompt" api/core/agent/base_agent_runner.py
# 3. CoT 循环
sed -n '49,90p' api/core/agent/cot_agent_runner.py # run 开头 + 停止词
# 4. FC 循环 + 真调工具
sed -n '55,90p' api/core/agent/fc_agent_runner.py # while 循环
sed -n '234,258p' api/core/agent/fc_agent_runner.py # ToolEngine.agent_invoke
core/plugin/ 和 core/mcp/:看 Dify 怎么把工具/模型能力做成可热插拔的插件(走独立的 plugin daemon 进程),以及怎么用 MCP 协议接入任意外部工具服务器。这是 Dify"生态可扩展"的地基。