Day 17 / 共 20 天 · 阶段5 工具与 Agent

Agent:让模型自己"想一步、做一步、再想一步"

Day16 我们造好了一柜子工具,但工具不会自己动。Agent 就是那个"大脑":拿到你的问题后,它自己判断"要不要用工具、用哪个、传什么参数",拿到结果再判断"够不够回答、要不要再来一轮"。今天进 core/agent/,弄清三件事:①Dify 的两种 Agent 策略 CoT 与 Function Calling 分别是什么、区别在哪;②它们共享的底座 BaseAgentRunner 做了什么;③两种策略各自的"循环"是怎么转起来、又怎么停下来的。

📍 你在 20 天里的位置(阶段5:工具与 Agent · D16-18)
D15 工作流收尾 D16 工具系统 D17 Agent D18 插件/MCP D19 任务队列/可观测 D20 收官全景
💡 先用两个类比兜住今天 类比一:Agent 像一位边查资料边解题的侦探。他不是一口气说出答案,而是"想一步(我需要先知道 X)→ 做一步(去查 X)→ 看结果(哦,X 是这样)→ 再想下一步",直到线索够了才下结论。这个"想-做-看"的循环就是 CoT(Chain of Thought)的灵魂。类比二:两种策略像点菜的两种方式。CoT 是"服务员没有菜单,你得用一段固定话术描述你要什么,他再猜";Function Calling 是"直接给你一份结构化菜单,你勾选、填数量,厨房照单做"——FC 更省事、更不容易出错,前提是模型本身支持这份"菜单协议"。
L01

痛点:工具有了,谁来指挥

🤔 痛点用户问:"帮我查一下上海今天的天气,如果超过 30 度就提醒我带伞。"这一句话里藏着:①要调天气工具;②拿到温度后要做判断;③可能还要调发消息工具。普通对话应用(Day04 的 ChatAppRunner)只会"一问一答",根本不会自己去调工具、更不会"看了结果再决定下一步"。缺的就是一个会"自主规划、循环执行"的大脑。
💡 本质:Agent = 一个"带工具的 while 循环"Agent 的核心其实朴素得惊人——就是一个循环:①把"问题 + 可用工具 + 历史"发给模型;②模型要么给出最终答案(结束),要么说"我要调某工具"(行动);③真的去调工具、拿到结果(观察);④把观察塞回历史,回到①。直到模型说"我能回答了"或者到达迭代上限。Dify 把这套循环封装成 AgentRunner,并提供两种"让模型表达行动"的方式:CoT(用文本约定)和 Function Calling(用结构化字段)。
L02

两种策略: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:40FunctionCallAgentRunner fc_agent_runner.py:36
大白话两种策略解决的是同一件事——"模型怎么告诉系统它想调哪个工具"。CoT 是"口头约定"(我们提前教模型:想调工具就按 Thought/Action 这个格式写),FC 是"官方接口"(模型厂商内置了一个专门返回工具调用的通道)。能用 FC 就优先 FC,更省心;老模型不支持 FC,就退回 CoT。
L03

BaseAgentRunner:两种策略的共同底座

两种 Runner 都继承 BaseAgentRunnercore/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_toolsbase_agent_runner.py:188)和它内部的 _convert_tool_to_prompt_message_toolbase_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_thoughtbase_agent_runner.py:223 / 265)每一轮"想了什么、调了什么工具、观察到什么"都存成一条 AgentThought 记录。你在 Dify 界面看到的 Agent 思考过程,就是它存的。
💡 为什么要有共同底座?CoT 和 FC 的区别只在"怎么表达行动",其余(准备工具菜单、存思考记录、管理会话历史、拿模型实例)完全一样。把公共部分抽到 BaseAgentRunner,两种策略就只需各写自己的 run()——"变化的部分"和"不变的部分"分离,正是模板方法/继承复用的经典用法,和 Day16 的 Tool 基类一个道理。
L04

CoT:思考-行动-观察的循环

CotAgentRunner.runcore/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
    ...

而模型输出的"行动"被解析成一个 Actioncot_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 和工具系统在这里接头。
CoT 的想-做-看循环(ReAct) ① Thought 想我需要先查天气 ② Action 做调 weather 工具 ③ Observation 看31℃(系统真调得的) 够了?是→final answer 否,再想一轮
图注:想→做→看→再想,直到"够了"给最终答案。"看"这一步一定由系统真调工具填,模型不能自己编。
L05

Function Calling:让模型直接吐"工具调用"

FunctionCallAgentRunner.runcore/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_callsfc_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 逐个调、结果都收集起来一起喂回。
L06

迭代上限:防止 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)   # ★ 到顶还想调工具 → 报错止损
⚠️ 坑:没有上限的 Agent 是"碎钞机"想象一个 Agent 陷入"查天气→查不到→再查天气→…"的死循环。每一轮都真的调一次大模型(要钱)、可能还调外部 API。没有 max_iteration,一个跑飞的 Agent 能在几分钟内烧掉大量 token 费用。所以 Dify 硬性封顶(最多 99 轮),到顶还没答案就诚实报错而不是继续烧。你配置 Agent 时那个"最大迭代次数"就是这里——设太小任务做不完,设太大有失控风险,需要按任务复杂度权衡。
💡 设计取舍:CoT vs FC 怎么选能用 FC 就 FC:结构化、解析稳、少踩"格式跑偏"的坑。模型不支持 FC(或想让思考过程更透明可读)才用 CoT。代价:CoT 靠解析文本,遇到模型不守格式就麻烦;但它兼容性最好、且推理过程天然可读。Dify 两种都留,把选择权交给你——这也是"平台"和"单一框架"的区别。
L07

串起来 + 今日小结

📝 真实值:Agent 处理"上海超 30 度就提醒带伞"(FC 策略,max_iteration=10) 第 1 轮:把问题 + 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
明日预告 · Day 18:今天两种策略调的工具,很多来自"插件"和"MCP"。明天进 core/plugin/core/mcp/:看 Dify 怎么把工具/模型能力做成可热插拔的插件(走独立的 plugin daemon 进程),以及怎么用 MCP 协议接入任意外部工具服务器。这是 Dify"生态可扩展"的地基。
← Day 16 工具系统 Day 18 · 插件与 MCP →