很多人第一次听到 Agent,会觉得这是一个很高级、很玄的概念。有人说它是智能体,有人说它是自动化助手,还有人说它以后能替代很多工作。说法很多,听起来也很热闹。

但如果我们把这些大词先放一边,Agent 其实可以用一句很朴素的话理解:

Agent 就是一个会自己判断下一步该怎么做,并能调用工具完成任务的程序。

普通聊天机器人更多是在“回答问题”。而 Agent 不只是回答,它还可以判断:这个问题要不要查资料?要不要算一下?要不要调用某个工具?工具算完之后,又应该怎样把结果解释给人听?

这就像我们平时请一个朋友帮忙。

你问:“我这个月花销是不是超了?”

一个只会聊天的人可能说:“你要注意理财。”

一个真正能帮忙的人会说:“你把账单给我,我先帮你分类,再算总支出,最后告诉你哪里花多了。”

Agent 的差别就在这里:它不只是说,它还要会做。

这篇文章基于两个小项目来讲 Agent 入门:

第一个项目:不用任何 Agent 框架,手写一个最小药学 Agent。
第二个项目:使用 LangGraph,构建一个具有短期记忆能力的药学 Agent。

这两个项目都围绕同一个任务展开:用户输入一个小分子的 SMILES,Agent 调用 RDKit 计算分子性质,然后用中文解释这个分子的口服药物性。

这里的重点不是药学本身,而是通过一个真实小例子,看懂 Agent 是怎么搭起来的。


一、Agent 到底比普通聊天机器人多了什么?

我们先从一个生活例子说起。

假如你问一个普通聊天机器人:

我想做番茄炒蛋,需要准备什么?

它可以直接回答:番茄、鸡蛋、葱、盐、油。

这个问题不需要工具,因为它靠常识就能回答。

但如果你问:

我冰箱里有两个番茄、三个鸡蛋、一盒豆腐,帮我算一下能做几道菜,并估计热量。

这时只靠聊天就不够了。它最好能做几件事:

  1. 先判断这个问题涉及食材和热量计算;

  2. 调用食材热量表或计算工具;

  3. 根据计算结果给出建议;

  4. 如果你继续问“刚才哪道菜热量最低”,它还要记得前面说过什么。

这就是 Agent 的基本思路。

对应到代码里,一个最小 Agent 通常包括四个东西:

用户问题
↓
大模型判断要不要调用工具
↓
如果需要,就调用工具
↓
大模型根据工具结果组织最终回答

所以,Agent 不是“大模型自己变成了万能专家”。更准确地说,Agent 是把大模型、工具、程序流程和记忆组合到一起。

可以把它理解为:

Agent = 大模型 + 工具 + 流程控制 + 记忆

其中:

  • 大模型负责理解问题和组织语言;

  • 工具负责做确定性的事情,比如计算、检索、查数据库;

  • 流程控制负责决定下一步走哪里;

  • 记忆负责保存上下文,让它不要每一轮都像第一次见你。


二、第一个项目:不用框架,写一个最小 Agent

我们先不要急着用 LangGraph、LangChain 这些框架。初学时,最重要的是先看清楚 Agent 的骨架。

第一个项目的目标很简单:

用户输入一个 SMILES,Agent 判断是否需要调用 RDKit。如果需要,就计算分子性质,再让大模型解释结果。

整体流程如下:

用户输入问题
↓
LLM 先做判断:是否需要调用 analyze_smiles 工具
↓
如果需要,程序调用 RDKit 工具
↓
RDKit 返回分子性质
↓
LLM 根据真实结果生成中文解释

这里的例子使用本地 Ollama 模型和 RDKit 工具。


三、准备环境

1. 创建 Python 环境

先创建一个新的 conda 环境:

conda create -n pharm_agent python=3.10 -c conda-forge rdkit -y
conda activate pharm_agent

如果 RDKit 没有正常安装,可以再执行:

conda install conda-forge::rdkit

安装 requests,用来调用本地 Ollama 接口:

pip install requests

2. 安装并准备 Ollama

Ollama 是一个可以在本地运行大模型的工具。安装好 Ollama 后,可以下载一个模型,例如:

ollama pull qwen2.5:7b

查看本地已经下载的模型:

ollama list

如果模型已经存在,就可以开始写代码。


四、完整代码:最小药学 Agent

新建一个文件:

touch pharm_agent_minimal.py

然后写入下面的代码。

1. 导入依赖

import json
import os
import re
import requests

from rdkit import Chem
from rdkit.Chem import Descriptors, Crippen, Lipinski, rdMolDescriptors, QED

这些包分成三类:

  • jsonre:用来解析模型输出;

  • requests:用来请求 Ollama 本地模型;

  • rdkit:用来计算分子性质。

2. 设置 Ollama 地址和模型名称

OLLAMA_URL = os.getenv("OLLAMA_URL", "http://localhost:11434/api/chat")
MODEL = os.getenv("OLLAMA_MODEL", "qwen2.5:7b")

这里默认调用本地 Ollama 的 chat 接口,模型默认是 qwen2.5:7b

如果你想换模型,也可以在终端里设置:

export OLLAMA_MODEL=llama3.1:8b

Windows PowerShell 可以写成:

$env:OLLAMA_MODEL="llama3.1:8b"

3. 封装大模型调用函数

def ollama_chat(messages, json_mode=False, temperature=0.0):
    """
    调用本地 Ollama 模型。
    json_mode=True 时,要求模型尽量返回 JSON。
    """
    payload = {
        "model": MODEL,
        "messages": messages,
        "stream": False,
        "options": {
            "temperature": temperature
        }
    }

    if json_mode:
        payload["format"] = "json"

    response = requests.post(OLLAMA_URL, json=payload, timeout=120)
    response.raise_for_status()
    data = response.json()
    return data["message"]["content"]

这个函数的作用很简单:把消息发给本地模型,然后拿回模型回复。

这里有一个小细节:

"temperature": 0.0

温度越低,模型回答越稳定。Agent 在做工具调用判断时,最好稳定一点,不要太“有创意”。

4. 解析模型返回的 JSON

def safe_json_loads(text):
    """
    尽量从模型输出中解析 JSON。
    """
    try:
        return json.loads(text)
    except json.JSONDecodeError:
        pass

    match = re.search(r"\{.*\}", text, re.DOTALL)
    if not match:
        raise ValueError(f"模型没有返回合法 JSON:
{text}")

    return json.loads(match.group(0))

为什么需要这个函数?

因为大模型有时候不太老实。你让它只输出 JSON,它可能会多说一句“好的,下面是结果”。这对人来说没问题,但对程序来说就很麻烦。

所以这里写了一个保险函数:如果不能直接解析,就尝试从模型输出里提取 {...} 这一段。

5. 编写 RDKit 工具函数

这个函数就是 Agent 的工具。

def analyze_smiles(smiles):
    """
    药学工具函数:
    使用 RDKit 计算一个小分子的基础药物性指标。
    """
    mol = Chem.MolFromSmiles(smiles)

    if mol is None:
        return {
            "ok": False,
            "error": "SMILES 无法被 RDKit 正确解析",
            "smiles": smiles
        }

    mw = Descriptors.MolWt(mol)
    logp = Crippen.MolLogP(mol)
    hbd = Lipinski.NumHDonors(mol)
    hba = Lipinski.NumHAcceptors(mol)
    tpsa = rdMolDescriptors.CalcTPSA(mol)
    rot_bonds = Lipinski.NumRotatableBonds(mol)
    qed = QED.qed(mol)
    fraction_csp3 = rdMolDescriptors.CalcFractionCSP3(mol)

    lipinski_rules = {
        "MW <= 500": mw <= 500,
        "LogP <= 5": logp <= 5,
        "HBD <= 5": hbd <= 5,
        "HBA <= 10": hba <= 10,
    }

    lipinski_violations = [
        rule for rule, passed in lipinski_rules.items() if not passed
    ]

    veber_rules = {
        "TPSA <= 140": tpsa <= 140,
        "Rotatable bonds <= 10": rot_bonds <= 10,
    }

    veber_violations = [
        rule for rule, passed in veber_rules.items() if not passed
    ]

    if qed >= 0.67:
        qed_level = "较高"
    elif qed >= 0.49:
        qed_level = "中等"
    else:
        qed_level = "偏低"

    if len(lipinski_violations) <= 1 and len(veber_violations) == 0:
        oral_druglike_judgement = "初步看具备较好的口服小分子药物性潜力"
    elif len(lipinski_violations) <= 2:
        oral_druglike_judgement = "存在一定口服药物性风险,需要进一步优化"
    else:
        oral_druglike_judgement = "口服小分子药物性风险较高"

    return {
        "ok": True,
        "smiles": smiles,
        "properties": {
            "molecular_weight": round(mw, 2),
            "logP": round(logp, 2),
            "hbd": hbd,
            "hba": hba,
            "tpsa": round(tpsa, 2),
            "rotatable_bonds": rot_bonds,
            "qed": round(qed, 3),
            "qed_level": qed_level,
            "fraction_csp3": round(fraction_csp3, 3),
        },
        "lipinski": {
            "rules": lipinski_rules,
            "violations": lipinski_violations,
            "num_violations": len(lipinski_violations),
        },
        "veber": {
            "rules": veber_rules,
            "violations": veber_violations,
            "num_violations": len(veber_violations),
        },
        "judgement": oral_druglike_judgement,
        "note": "该结果仅用于计算药物化学初筛,不代表真实活性、毒性、成药性或临床有效性。"
    }

这个工具函数只做一件事:根据 SMILES 算分子性质。

它不负责写漂亮的解释,也不负责判断用户意图。它就像厨房里的电子秤,只负责称重,不负责教你做菜。

6. 注册工具

TOOLS = {
    "analyze_smiles": analyze_smiles
}

这里把工具放进一个字典里。后面大模型如果说要调用 analyze_smiles,程序就从这个字典里找到对应函数。

7. 编写 Agent 主函数

这是整个最小 Agent 的核心。

def agent_run(user_question):
    """
    最小 Agent:
    1. 让 LLM 决定是否调用工具;
    2. 如果调用工具,则执行工具;
    3. 再让 LLM 基于工具结果生成药学解释。
    """

    planner_system_prompt = """
你是一个药学研究 Agent。你可以根据用户问题决定是否调用工具。

你只有一个工具:
工具名:analyze_smiles
作用:根据 SMILES 计算小分子的基础药物性指标,包括分子量、LogP、HBD、HBA、TPSA、可旋转键、QED、Lipinski 和 Veber 规则。

你必须只输出 JSON,不要输出其他文字。

如果用户提供了 SMILES,并且希望分析药物性、成药性、口服性质、类药性、小分子性质,则输出:
{
  "action": "analyze_smiles",
  "arguments": {
    "smiles": "用户提供的SMILES"
  }
}

如果用户没有提供 SMILES,或者不需要工具,则输出:
{
  "action": "final",
  "answer": "请用户提供SMILES,或直接回答用户问题"
}

不要编造计算结果。
"""

    planner_messages = [
        {"role": "system", "content": planner_system_prompt},
        {"role": "user", "content": user_question}
    ]

    plan_text = ollama_chat(planner_messages, json_mode=True)
    plan = safe_json_loads(plan_text)

    action = plan.get("action")

    if action == "final":
        return plan.get("answer", "请提供更具体的问题。")

    if action not in TOOLS:
        return f"未知工具:{action}"

    tool_args = plan.get("arguments", {})
    tool_result = TOOLS[action](**tool_args)

    reporter_system_prompt = """
你是一个药物化学与 AIDD 研究助手。
请根据工具返回的真实计算结果,用中文生成研究解读。

要求:
1. 先给出核心判断;
2. 再解释关键指标;
3. 指出潜在风险;
4. 给出下一步研究建议;
5. 不要声称该分子一定有效;
6. 不要给临床用药建议。
"""

    reporter_messages = [
        {"role": "system", "content": reporter_system_prompt},
        {"role": "user", "content": f"用户原始问题:{user_question}"},
        {"role": "user", "content": f"工具计算结果:{json.dumps(tool_result, ensure_ascii=False, indent=2)}"}
    ]

    final_answer = ollama_chat(reporter_messages, json_mode=False)
    return final_answer

这段代码可以拆成两次大模型调用。

第一次调用大模型,是让它做“计划”:

这个问题要不要调用工具?
如果要,调用哪个工具?参数是什么?

第二次调用大模型,是让它做“汇报”:

工具结果已经出来了,请用人能听懂的话解释一下。

这就是最小 Agent 的关键。

它不是一上来就让大模型直接回答,而是先让模型决定是否要借助工具。

8. 添加命令行运行入口

if __name__ == "__main__":
    print("药学最小 Agent 已启动。输入 exit 退出。")
    print("示例:请分析阿司匹林的口服药物性,SMILES: CC(=O)Oc1ccccc1C(=O)O")
    print("-" * 80)

    while True:
        question = input("
你:").strip()

        if question.lower() in ["exit", "quit", "q"]:
            break

        try:
            answer = agent_run(question)
            print("
Agent:")
            print(answer)
        except Exception as e:
            print("
运行出错:")
            print(e)

运行脚本:

python pharm_agent_minimal.py

输入测试问题:

请分析阿司匹林的口服药物性,SMILES: CC(=O)Oc1ccccc1C(=O)O

你会看到 Agent 先调用 RDKit,再生成类似这样的回答:

核心判断:
阿司匹林初步看具备较好的口服小分子药物性潜力。

关键指标解释:
分子量、LogP、氢键供体、氢键受体、TPSA、可旋转键等指标基本符合 Lipinski 和 Veber 规则。

潜在风险:
这些指标只能说明基础药物性较好,不能代表真实活性、毒性和临床有效性。

下一步建议:
可以进一步结合活性实验、毒性评估、代谢稳定性和药代动力学研究。

再测试咖啡因:

请分析咖啡因是否具备口服小分子药物性,SMILES: Cn1cnc2c1c(=O)n(C)c(=O)n2C

这时 Agent 也会调用同一个工具,然后给出咖啡因的分子性质解释。


五、这个最小 Agent 的问题:它没有记忆

第一版 Agent 已经能工作了,但它有一个明显问题:它不记得前面说过什么。

比如你先问:

请分析阿司匹林的口服药物性,SMILES: CC(=O)Oc1ccccc1C(=O)O

它回答完之后,你再问:

刚才这个分子的主要风险是什么?

它很可能回答不上来,或者要求你重新提供 SMILES。

这不是因为它笨,而是因为我们第一版程序没有保存上下文。每次提问,都是一个新的请求。程序没有把上一轮的问题、工具结果和回答保存下来。

这就像你去窗口办业务,刚递完材料,工作人员立刻把材料扔了。你下一句问“刚才那个材料有什么问题”,他当然不知道。

所以第二版 Agent 要解决的问题就是:让它有短期记忆。


六、第二个项目:使用 LangGraph 构建有记忆的 Agent

LangGraph 可以把 Agent 设计成一个“图”。

这个图里有节点,有边,也有条件判断。

在我们的例子里,图很简单:

START
↓
assistant 节点
↓
判断是否需要调用工具
↓             ↓
tools 节点      直接结束
↓
assistant 节点总结工具结果

可以把它理解成一个办事流程:

  1. assistant 先接待用户;

  2. 如果发现需要计算,就把任务交给 tools

  3. tools 算完后,把结果交回 assistant

  4. assistant 再把结果解释给用户。

这比第一版更清楚,也更适合扩展。


七、安装 LangGraph 相关包

在原来的环境中安装:

pip install -U langgraph langchain langchain-core langchain-ollama

准备一个支持工具调用的本地模型,例如:

ollama pull llama3.1:8b

这里使用 llama3.1:8b,主要是因为它在 Ollama 中比较常用,也支持 tool calling,适合做本地 Agent 实验。


八、完整代码:LangGraph 药学 Agent

新建文件:

touch pharm_langgraph_agent.py

1. 导入依赖

import json
import os
from typing import Any, Dict

from rdkit import Chem
from rdkit.Chem import Descriptors, Crippen, Lipinski, rdMolDescriptors, QED

from langchain_ollama import ChatOllama
from langchain_core.tools import tool
from langchain_core.messages import SystemMessage, HumanMessage

from langgraph.graph import StateGraph, START, MessagesState
from langgraph.prebuilt import ToolNode, tools_condition
from langgraph.checkpoint.memory import InMemorySaver

这里比第一版多了 LangGraph 和 LangChain 相关组件。

核心组件有几个:

  • ChatOllama:调用 Ollama 模型;

  • @tool:把普通函数包装成大模型可调用的工具;

  • StateGraph:构建图;

  • ToolNode:专门执行工具调用;

  • tools_condition:判断模型是否请求调用工具;

  • InMemorySaver:保存短期对话状态。

2. 定义 RDKit 工具

@tool
def analyze_smiles(smiles: str) -> str:
    """
    Analyze a small molecule from SMILES using RDKit.
    This tool calculates basic drug-likeness properties:
    molecular weight, LogP, HBD, HBA, TPSA, rotatable bonds,
    QED, fraction Csp3, Lipinski rule violations, and Veber rule violations.

    Args:
        smiles: A valid molecule SMILES string.
    """
    mol = Chem.MolFromSmiles(smiles)

    if mol is None:
        return json.dumps(
            {
                "ok": False,
                "smiles": smiles,
                "error": "SMILES 无法被 RDKit 正确解析。"
            },
            ensure_ascii=False
        )

    mw = Descriptors.MolWt(mol)
    logp = Crippen.MolLogP(mol)
    hbd = Lipinski.NumHDonors(mol)
    hba = Lipinski.NumHAcceptors(mol)
    tpsa = rdMolDescriptors.CalcTPSA(mol)
    rot_bonds = Lipinski.NumRotatableBonds(mol)
    qed = QED.qed(mol)
    fraction_csp3 = rdMolDescriptors.CalcFractionCSP3(mol)

    lipinski_rules = {
        "MW <= 500": mw <= 500,
        "LogP <= 5": logp <= 5,
        "HBD <= 5": hbd <= 5,
        "HBA <= 10": hba <= 10,
    }

    veber_rules = {
        "TPSA <= 140": tpsa <= 140,
        "Rotatable bonds <= 10": rot_bonds <= 10,
    }

    lipinski_violations = [
        rule for rule, passed in lipinski_rules.items() if not passed
    ]

    veber_violations = [
        rule for rule, passed in veber_rules.items() if not passed
    ]

    if qed >= 0.67:
        qed_level = "较高"
    elif qed >= 0.49:
        qed_level = "中等"
    else:
        qed_level = "偏低"

    if len(lipinski_violations) <= 1 and len(veber_violations) == 0:
        judgement = "初步看具备较好的口服小分子药物性潜力"
    elif len(lipinski_violations) <= 2:
        judgement = "存在一定口服药物性风险,需要进一步优化"
    else:
        judgement = "口服小分子药物性风险较高"

    result: Dict[str, Any] = {
        "ok": True,
        "input_smiles": smiles,
        "canonical_smiles": Chem.MolToSmiles(mol),
        "properties": {
            "molecular_weight": round(mw, 2),
            "logP": round(logp, 2),
            "hbd": hbd,
            "hba": hba,
            "tpsa": round(tpsa, 2),
            "rotatable_bonds": rot_bonds,
            "qed": round(qed, 3),
            "qed_level": qed_level,
            "fraction_csp3": round(fraction_csp3, 3),
        },
        "lipinski": {
            "rules": lipinski_rules,
            "violations": lipinski_violations,
            "num_violations": len(lipinski_violations),
        },
        "veber": {
            "rules": veber_rules,
            "violations": veber_violations,
            "num_violations": len(veber_violations),
        },
        "judgement": judgement,
        "important_note": "该结果仅用于早期药物化学与 AIDD 初筛,不代表真实活性、毒性、药效或临床可用性。"
    }

    return json.dumps(result, ensure_ascii=False, indent=2)

和第一版相比,这里最大的变化是加了:

@tool

这个装饰器会告诉 LangChain 和 LangGraph:这个函数不是普通函数,而是一个可以被大模型调用的工具。

3. 绑定模型和工具

MODEL_NAME = os.getenv("OLLAMA_MODEL", "llama3.1:8b")

llm = ChatOllama(
    model=MODEL_NAME,
    temperature=0
)

tools = [analyze_smiles]
llm_with_tools = llm.bind_tools(tools)

这里做了两件事:

第一,创建本地大模型:

llm = ChatOllama(model=MODEL_NAME, temperature=0)

第二,把工具绑定给大模型:

llm_with_tools = llm.bind_tools(tools)

绑定之后,模型在回答时就可以生成工具调用请求。

4. 编写系统提示词

SYSTEM_PROMPT = """
你是一个药物化学与 AIDD 研究 Agent。

你的任务:
1. 如果用户提供了 SMILES,并询问药物性、类药性、口服性质、ADMET 初筛、分子性质等问题,你应该调用 analyze_smiles 工具。
2. 工具返回结果后,你需要基于真实计算结果进行药学解释。
3. 不要编造计算结果。
4. 不要给临床用药建议。
5. 回答结构建议包括:
   - 核心判断
   - 关键指标解释
   - 风险点
   - 下一步研究建议

你可以处理的问题示例:
- 请分析阿司匹林的口服药物性,SMILES: CC(=O)Oc1ccccc1C(=O)O
- 这个分子是否符合 Lipinski 规则?
- 这个分子的 QED 和 TPSA 如何解读?
"""

提示词的作用不是装饰,而是给 Agent 定规矩。

它告诉模型:什么时候要调用工具,什么时候不能乱编,回答应该长什么样。

5. 定义 assistant 节点

def assistant_node(state: MessagesState):
    """
    LLM 节点:
    接收当前 messages,根据用户问题决定:
    1. 直接回答;
    2. 或发起工具调用。
    """
    response = llm_with_tools.invoke(
        [SystemMessage(content=SYSTEM_PROMPT)] + state["messages"]
    )

    return {"messages": [response]}

这个节点就是 Agent 的“大脑”。

它会读取当前对话,然后判断:

  • 要不要直接回答;

  • 要不要调用工具;

  • 如果调用工具,要调用哪个工具。

6. 构建 LangGraph 图

def build_graph():
    """
    构建最小 LangGraph Agent:
    START -> assistant
    assistant -> tools or END
    tools -> assistant
    """
    builder = StateGraph(MessagesState)

    builder.add_node("assistant", assistant_node)
    builder.add_node("tools", ToolNode(tools))

    builder.add_edge(START, "assistant")

    # tools_condition 会检查最后一条 AIMessage 是否包含 tool_calls。
    # 如果包含,则进入 tools 节点;否则结束。
    builder.add_conditional_edges("assistant", tools_condition)

    # 工具执行完后,把工具结果交回 assistant 继续总结。
    builder.add_edge("tools", "assistant")

    # 加入短期记忆。相同 thread_id 会保留多轮对话状态。
    checkpointer = InMemorySaver()
    graph = builder.compile(checkpointer=checkpointer)

    return graph


graph = build_graph()

这段代码是 LangGraph 版本的核心。

可以按生活里的流程理解:

  • assistant 像前台,先接待用户;

  • tools 像后厨或实验室,负责干具体的活;

  • tools_condition 像分诊台,判断要不要送去工具那边;

  • InMemorySaver 像临时笔记本,记住当前对话。

这就是有记忆 Agent 的雏形。

7. 编写运行函数

def run_agent(user_input: str, thread_id: str = "pharm-demo"):
    """
    运行 Agent。
    thread_id 相同,表示同一个对话线程,可以保留上下文。
    """
    config = {
        "configurable": {
            "thread_id": thread_id
        }
    }

    result = graph.invoke(
        {
            "messages": [
                HumanMessage(content=user_input)
            ]
        },
        config=config
    )

    # 找到最后一条 AI 回复
    for message in reversed(result["messages"]):
        if message.type == "ai" and not getattr(message, "tool_calls", None):
            return message.content

    return result["messages"][-1].content

这里最重要的是:

thread_id: str = "pharm-demo"

只要 thread_id 相同,LangGraph 就会把这些对话当成同一个会话。这样,Agent 才能记住上一轮发生过什么。

如果你把 thread_id 换掉,它就像开启了一个新聊天,自然不会记得之前的内容。

8. 添加命令行入口

if __name__ == "__main__":
    print("LangGraph 药学 Agent 已启动。输入 exit 退出。")
    print("当前模型:", MODEL_NAME)
    print("示例:请分析阿司匹林的口服药物性,SMILES: CC(=O)Oc1ccccc1C(=O)O")
    print("-" * 80)

    while True:
        user_input = input("
你:").strip()

        if user_input.lower() in ["exit", "quit", "q"]:
            break

        try:
            answer = run_agent(user_input)
            print("
Agent:")
            print(answer)
        except Exception as e:
            print("
运行出错:")
            print(type(e).__name__, str(e))

运行:

python pharm_langgraph_agent.py

测试第一轮:

请分析阿司匹林的口服药物性,SMILES: CC(=O)Oc1ccccc1C(=O)O

再测试第二轮:

第一个分子的主要风险是什么?

这时它就能根据之前的上下文继续回答。

再问:

针对这个风险有哪些优化方式?

它也能继续沿着上一轮往下说。

这就是短期记忆带来的变化。


九、两版 Agent 有什么区别?

可以用一张表说明。

对比项 最小手写版 Agent LangGraph 版 Agent
是否使用框架 不使用 使用 LangGraph
工具调用方式 自己让模型输出 JSON,再手动解析 使用 bind_tools 和 ToolNode
流程控制 自己写 if 判断 用图结构和条件边控制
记忆能力 基本没有 可通过 thread_id 保留短期上下文
适合阶段 初学,理解原理 进阶,构建可扩展工作流
优点 简单透明,容易看懂 结构清楚,方便扩展
缺点 扩展性较弱 初学时理解成本略高

如果用生活例子来说:

手写版 Agent 像你临时找一个朋友帮忙。你把问题告诉他,他判断一下,去查个工具,再回来告诉你结果。

LangGraph 版 Agent 像一个小型办事窗口。前台、分诊、工具部门、记录本都有了,流程更规范,也更容易继续扩展。


十、Agent 开发的关键不是“框架”,而是“分工”

学 Agent,最重要的不是先记住多少框架名,而是先理解分工。

在这个例子里,分工非常清楚:

用户:提出问题
LLM:理解问题、判断是否调用工具、解释结果
RDKit:负责真实计算
程序:负责把 LLM 和工具连接起来
LangGraph:负责管理流程和状态

如果分工不清,就容易出问题。

比如:

  • 让大模型直接编造分子量,这是不可靠的;

  • 让 RDKit 写中文研究建议,这是不合适的;

  • 没有程序控制流程,工具和模型就接不上;

  • 没有记忆机制,多轮对话就会断掉。

一个好的 Agent,不是让一个模型包打天下,而是让不同角色各自做自己擅长的事。

这和生活里做饭很像。

菜谱负责告诉你步骤,刀负责切菜,锅负责加热,人负责判断火候。如果你让锅去决定今天吃什么,或者让菜谱自己把菜炒熟,事情就乱了。

Agent 也是一样。


十一、初学者最容易踩的坑

1. 一上来就学大框架

很多人刚开始学 Agent,就直接看复杂框架。结果越看越迷糊。

更好的方式是:先手写一个最小 Agent,哪怕功能很简单。只要你看懂了“判断 → 工具调用 → 结果解释”这个闭环,再学框架就容易很多。

2. 没有控制模型输出格式

第一版里让模型输出 JSON,这是很重要的一步。

因为程序要解析模型输出。如果模型随便输出自然语言,程序就不知道该调用哪个工具,也不知道参数是什么。

所以规划阶段最好要求模型输出结构化内容。

3. 把工具结果和模型回答混在一起

工具负责算,模型负责解释。

比如 RDKit 算出分子量是 180.16,那么模型只能基于这个结果解释,不能自己改成 190,也不能说“这个分子一定安全有效”。

4. 忘了做异常处理

如果用户输入了错误 SMILES,程序不能直接崩溃。工具应该返回清楚的错误信息:

{
  "ok": false,
  "error": "SMILES 无法被 RDKit 正确解析"
}

这样大模型才能根据错误信息继续解释。

5. 以为用了 LangGraph 就一定有记忆

LangGraph 可以支持记忆,但你要正确使用 checkpointerthread_id

如果每次都换一个 thread_id,那它还是记不住。

记忆不是口号,本质上就是状态保存。


十二、下一步可以怎么扩展?

这个药学 Agent 只是一个入门例子。跑通之后,可以继续扩展。

1. 增加更多工具

现在只有一个工具:

analyze_smiles

以后可以增加:

预测 ADMET
查询 ChEMBL
分子相似性搜索
靶点信息查询
分子结构可视化
对接结果解析

这样 Agent 就不只是算几个分子性质,而是能完成更完整的 AIDD 初筛任务。

2. 增加长期记忆

现在的 InMemorySaver 更像临时记忆,程序关掉后就没了。

以后可以把重要信息保存到:

SQLite
PostgreSQL
MongoDB
向量数据库
本地 JSON 文件

比如保存用户常用的靶点、分子库、筛选标准、分析偏好。

3. 增加多步任务规划

现在的 Agent 只做简单判断。

以后可以让它完成更复杂的任务:

用户上传一批 SMILES
↓
Agent 自动批量计算性质
↓
筛选符合规则的分子
↓
生成 Excel 表格
↓
总结候选分子的优缺点

这时 Agent 就从“小工具助手”变成了“小型工作流助手”。

4. 增加人类确认环节

对于重要任务,Agent 不应该完全自动执行。

比如删除文件、提交任务、发送邮件、修改数据库,都应该让用户确认。

可靠的 Agent 不只是会做事,还要知道哪些事不能擅自做。


十三、总结:Agent 入门先抓住一条主线

Agent 入门,不要先被各种名词吓住。

先记住这条主线:

用户提出任务
↓
大模型理解任务
↓
判断是否需要工具
↓
工具执行具体操作
↓
大模型解释工具结果
↓
必要时保存上下文,支持下一轮对话

第一个项目告诉我们:不用框架,也能写出一个最小 Agent。
第二个项目告诉我们:当任务变成多轮对话和可扩展流程时,LangGraph 这样的框架会更方便。

所以,学习 Agent 最好的路径不是一开始就追求复杂,而是先跑通一个小闭环。

就像学做饭,不必第一天就做满汉全席。先把番茄炒蛋做熟,知道什么时候放油、什么时候下蛋、什么时候加盐,再谈宴席。

Agent 也是一样。

先让它正确调用一个工具,正确解释一次结果,正确记住上一轮对话。把这些小事做好,真正的 Agent 能力就开始长出来了。

Logo

有“AI”的1024 = 2048,欢迎大家加入2048 AI社区

更多推荐