01 认识 LLM 与 Agent

本章是整套教程的地基。它不讲数学、不抄官方文档,而是用你十几年的 Java 工程经验,把 LLM 这个"黑盒"翻译成你熟悉的心智模型:一个无状态的概率接口、一个会自动续写的协作者、一套需要你自己实现上下文的"会话"机制。读完本章,你会明白后面所有章节(Prompt 工程、RAG、Agent 框架、Function Calling)为什么是这么设计的。

本章你将学到

  • 用「无状态概率接口 + 自回归续写」的心智模型理解 LLM,掌握 Token、上下文窗口、temperature / top_p / max_tokens 三个生成参数。
  • 理解幻觉和算术错误为什么是 LLM 的结构性缺陷而非 bug,以及工程上为什么必须把"精确计算"交给代码。
  • 掌握 Function Calling(工具调用)的完整机制:模型输出结构化调用指令、宿主代码执行、结果回填、模型继续。并跑通第一个"模型调用你写的代码"的最小示例。
  • 分清「普通 LLM 调用 / 固定 Workflow / Agent」三者的边界,了解 LangChain、LangGraph、LangChain4j、Spring AI 的定位,以及 AutoGPT 等早期自主 Agent 失败带来的工程教训。

前置知识

  • 扎实的 Java 后端经验:HTTP 无状态服务、RPC、状态机、事件循环、测试与可观测性。本章反复用这些概念做类比,绝不讲解 Java 基础。
  • 能读懂 Python 即可(示例代码用 Python 3.10+ 编写,全部完整可运行;不需要你会写)。
  • 一个可用的 LLM API Key:OpenAI 官方,或任意 OpenAI 兼容端点(DeepSeek https://api.deepseek.com/v1、阿里云百炼 Qwen https://dashscope.aliyuncs.com/compatible-mode/v1 等)。环境准备见 ./00-导读.md。

1. LLM 是什么:一个「逐 Token 续写」的模型

先建立一个朴素的函数视角:LLM 不是大脑,不是数据库,也不是计算器。它是一个函数,输入一段文本,输出下一个 Token 的概率分布。

f(已生成的 Token 序列) -> 下一个 Token 的概率分布

训练与推理的细节我们在第 2、3 章展开,这里只需要抓住四个关键事实:输入输出都是 Token、有上下文窗口、自回归逐 Token 生成、生成行为受采样参数控制。

1.1 输入输出都是 Token

文本进入模型之前,先被拆成 Token(词元)。Token 是 LLM 世界的最小计量单位,相当于 HTTP 报文里的"字节"、计费里的"请求量"。

分词示例(示意,不同模型分词器略有差异):

"CA1234 明天延误吗?"  ->  ["CA1234", " 明天", "延误", "吗", "?"]

量级直觉(以官方文档为准):

  • 英文:约 1000 Token ≈ 750 个英文单词。
  • 中文:一个汉字通常占 1~2 个 Token(取决于分词器,中文常见约 1 个汉字 ≈ 1.5 Token 上下)。
  • 计费按 Token 数:输入 Token 和输出 Token 单价不同(输出通常更贵),每一轮对话都重新计费。

Java 类比:Token 之于 LLM,如同字节之于 TCP 报文。你的"请求体"和"响应体"都不是以字符为单位,而是以 Token 为单位——这决定了成本估算和窗口占用的第一近似公式:单次调用成本 ≈ (输入 Token + 输出 Token) × 单价。

1.2 上下文窗口

模型一次能"看到"的 Token 总量叫上下文窗口(Context Window),且输入和输出共享这个窗口。

Java 类比:上下文窗口 ≈ 请求体大小上限。区别在于,HTTP 请求体超限你立刻收到 413,而上下文超限时模型要么拒绝生成、要么服务端直接截断/报错——两者都需要你提前预判。

工程要点:

  • 窗口大小因模型而异(如 128K 这类量级),以官方文档为准。窗口大不等于能力强:实验普遍观察到模型对"窗口中间位置"的内容关注较弱(所谓 Lost in the Middle 现象),工程上重要的约束要放在开头和结尾附近。
  • 上下文越长,单次请求越贵、延迟越高。把"所有历史都塞进去"是最昂贵的偷懒方式,第 4、6 章会讲上下文管理的工程手段。

1.3 自回归生成:一次只续写一个 Token

LLM 生成答案的方式是自回归(Autoregressive):把已生成的所有 Token 拼回输入,预测下一个 Token 的概率分布,采样一个拼上去,重复直到输出结束符或达到上限。整个生成过程不会回头修改已生成的 Token。

flowchart LR
    S["给定已生成的 Token 序列"] --> P["模型计算下一个 Token 的概率分布"]
    P --> C["按分布采样一个 Token<br/>(受 temperature / top_p 影响)"]
    C --> A["拼接到序列末尾"]
    A -->|"未遇到结束符且未达到 max_tokens"| P
    A -->|"遇到结束符或到达上限"| E["输出完成"]

"概率分布"是理解一切的钥匙。假设模型正在续写"CA1234 明天是否……",它内部算出的可能是:

下一个 Token概率
延误0.82
取消0.09
准点0.06
其他0.03

它选"延误",因为"延误"在它见过的海量语料里最常跟在这个上下文后面——它是在猜"人类最可能怎么写",不是在查"事实是什么"。这一点直接决定了第 2 节说的幻觉和算错。

工程启示(先记住结论):输出天然不确定,长度天然不可预测(像流式响应没有 Content-Length)。所以任何要求"字节级稳定"的产物(金额、航班号、日期、JSON 结构)都要靠输出解析 + 校验兜底,而不是指望模型自觉。

1.4 生成参数:temperature / top_p / max_tokens

这三个参数在每次请求时传入,属于"请求参数"而非模型配置,对应 Java 里方法签名上的入参。所有对话模型都支持,具体范围以官方文档为准(OpenAI 为 0~2)。

参数作用Java 类比工程建议
temperature控制采样的随机性。0 时近似贪心(每次选概率最高的),越大长尾 Token 越容易被选中无直接对应;可理解为"把选择范围放宽的程度"工具调用、结构化输出、事实问答建议 0 或接近 0;文案创意类再调高
top_p核采样:只从累积概率达到 top_p 的候选集合里采样相当于"截断候选集"与 temperature 二选一调即可,官方不建议同时调
max_tokens单次输出(含推理内容)的 Token 上限响应大小上限 + 超时保护必设,且要比上下文窗口留出输入余量

一个容易踩的坑:max_tokens 限制的是输出 Token 数,不包含输入。输入很长时,输入 + 输出的总和仍受上下文窗口约束,所以 max_tokens 不能顶到窗口那么大。另外 OpenAI 部分新模型(o 系列)把该参数改名为 max_completion_tokens,LangChain 里两种写法均可传,具体以官方文档为准。

2. 为什么 LLM 会「幻觉」,三位数乘法也会算错

理解了自回归,这两个现象就是必然结论,而不是 bug。

幻觉(Hallucination)的根因:模型的知识全部压缩在参数里,本质是"语料中的统计相关性",不是一个可以查询的事实库。当它不知道答案时,不会像数据库那样返回空集,而是依据"最像人类会怎么写"续写一段——于是就有了言之凿凿的编造。

Java 类比:想象一个从不抛异常、永远能给出回答的资深同事,他的回答风格永远自信,但他对不确定的事情也会说得很笃定。你没法靠"给他打日志"修复他,只能改变你使用他的方式。

三位数乘法会算错:因为乘法不是"续写"能精确完成的。123 × 456 这类运算在模型见过的语料里没有足够的、一一对应的"算式→结果"模式可供统计,它只能凭模糊印象续写一个接近的值。少数情况能算对,是因为那些算式出现在训练语料里——这是记忆,不是计算。

工程启示(整套教程最重要的一条):

把"必须精确"的环节(算钱、查航班、下单、写库、校验)全部留给代码和数据库;LLM 只负责"理解意图、拆解任务、组织表达"。 这正是一系列外层机制(Function Calling、RAG、Agent 工具循环)存在的根本理由。

缓解幻觉的现实手段(第 4~6 章展开):检索增强(RAG)把事实塞进输入、让模型"基于给定材料回答";Function Calling 让模型"去查而不是去编";Prompt 里明确"不知道就说不确定";输出校验在宿主侧拦截明显错误。

3. Java 视角类比:LLM 是一个无状态概率接口

下面这张对照表是整个教程的地图,建议背下来:

LLM 概念Java 类比关键差异
一次模型调用一次 HTTP 请求请求参数(Prompt)决定一切,服务端零状态
Prompt / Messages方法入参不是强类型 JSON,是自然语言,模型可能不照做
System Prompt项目级契约说明 / 常量模板模型不保证遵守,只能靠工程约束强化
上下文窗口请求体大小上限输入输出共享窗口,超限被截断
上下文 / 历史记忆会话状态(Session)没有 Cookie、没有 Session 池,历史必须你手动拼进请求
Token报文中的词元既是计量单位又是计费单位
temperature / top_p无对应概率采样参数,只影响这一"笔"请求
Function CallingRPC 协议(接口定义 + 参数序列化)发起调用的"客户端"是模型,契约格式是 JSON Schema
Embedding文本到特征向量的编码器输出可做语义相似度检索
RAGService 层先查数据库、再拼 DTO 调用下游"数据库"是按语义相似度检索的文本库

其中最重要的一条:LLM 是无状态的。它不像你的服务端会话里有 HttpSession 帮你记住用户,也不像数据库有持久化。上下文(Context)完全由调用方负责,缺了历史,它就是一个"只有当下这一句话"的服务。

这正是为什么所有 Agent 框架(包括第 5 章我们的手写循环)都围绕"维护一个 messages 列表"转——上下文管理是 Agent 工程的第一大难点,后续章节反复出现的记忆、裁剪、摘要、持久化,本质上都在解决"无状态接口 + 需要跨多轮的状态"这个矛盾。

4. 三个关键能力:In-Context Learning / Instruction Following / Few-shot

新手最容易高估 LLM,也最容易低估这三件事。它们都不是训练技术,而是模型的推理期能力——不需要更新参数。

In-Context Learning(上下文内学习):模型能仅凭输入里给出的描述和示例,执行一个它可能没被专门训练过的任务。Java 类比:不改一行代码,靠"入参"里塞进一份新契约,就改变了方法行为。这是"Prompt 就是代码"说法的来源,也是 Prompt 工程能成立的前提。

Instruction Following(指令遵循):模型能"听指令"而不是单纯续写——这是 2022 年 InstructGPT / ChatGPT 通过 RLHF(人类反馈强化学习)带来的关键跃迁。注意:遵循是概率性的,不是契约性的。System Prompt 里的指令违反率不为零,工程上要用后面的机制(结构化输出、校验、工具约束)去兜底。

Few-shot(少样本示例):在 Prompt 里给出少量输入-输出对,让模型模仿。

零样本(Zero-shot):  "请把下面这句话归类为 改签/退票/普通咨询:我想改到明天上午的航班"
少样本(Few-shot):  加 2~3 个"问题->答案"的例子再让模型作答

工程要点:

  • 示例要覆盖边界情况(拒单、未知航班号、语气恶劣),示例格式必须与真实请求一致。
  • Few-shot 直接增加输入 Token,是成本换质量,够用就好(2~5 个示例是常见量级)。
  • 与 Fine-tuning(微调)区分:微调是改模型参数(重训练),成本高、周期长;In-Context 是"运行时行为",零成本切换。能 In-Context 解决的不要微调,这是当前工程实践的主流判断。

System Prompt 的初步工程实践(后续章节还会深化):明确角色与任务边界;给出硬性约束(如"只使用提供的工具,不要编造航班状态");指定输出格式;给一句兜底措辞("信息不足时回复:需要更多信息")。再次强调:这些是"尽力而为"的约定,宿主侧必须校验。

5. Function Calling:让模型调用你的代码

5.1 机制:一个四步的 RPC 往返

Function Calling(工具调用)是 2023 年 6 月 OpenAI 在 Chat Completions 中推出的能力。它解决的核心问题是:在它出现之前,让模型"调用工具"只能靠提示词约定 + 解析模型输出的文本,脆弱到不可用。它本质上是给 LLM 定义了一种结构化调用协议。

sequenceDiagram
    participant U as 用户
    participant H as 你的服务(宿主代码)
    participant L as LLM
    U->>H: "CA1234 明天准点吗?"
    H->>L: messages + 工具 JSON Schema 描述
    L-->>H: tool_calls: get_flight_status(flight_no="CA1234")
    H->>H: 宿主执行真实函数(查离港系统/第三方接口)
    H->>L: messages 追加 role=tool 的结果消息
    L-->>H: 最终回答: "CA1234 预计延误约 45 分钟"
    H->>U: 返回给用户

关键事实,初学者最易误解的几点:

  • 模型不执行任何代码。它只输出"调用指令"(工具名 + 参数),执行永远发生在你的宿主机里。等价于:模型是"会写调用代码的调用方",你的服务是"被调方"。
  • 模型可能一次要求调用多个工具(并发/并行),宿主需逐条执行、逐条回填。
  • 回填时必须携带工具调用的 id,模型靠它把"结果"和"那一次调用"对齐——类似分布式追踪里靠 traceId 对齐请求与响应。
  • 模型可以在工具结果之上再调工具(查完航班再查天气),这就是后面 Agent 循环的雏形。

OpenAI 原始响应里 tool_calls 的形态(LangChain 会把 arguments 从字符串解析成对象再暴露给你):

{
  "id": "call_9f8...",
  "type": "function",
  "function": {
    "name": "get_flight_status",
    "arguments": "{\"flight_no\": \"CA1234\", \"date\": \"2026-09-21\"}"
  }
}

LangChain 归一化后的形态(AIMessage.tool_calls):

[
  {
    "name": "get_flight_status",
    "args": {"flight_no": "CA1234", "date": "2026-09-21"},
    "id": "call_9f8...",
    "type": "tool_call"
  }
]

Java 类比补全:JSON Schema ≈ IDL / WSDL;宿主侧参数校验 ≈ 反序列化防御(模型给的类型和枚举可能不合法,比如把日期写成 2026/09/21),必须在你执行真实代码前兜住。

5.2 最小可运行示例:bind_tools + 手动工具循环

下面这个示例完整可运行,它手工实现了上图的四步循环——不依赖任何 Agent 框架,让你看清本质。真实项目的 Agent 框架只是把这个循环做成了可控的引擎(第 2 章后展开)。

# 依赖安装(Python 3.10+)。注意版本固定为 langchain 0.3.x 系列
pip install "langchain==0.3.*" "langchain-openai==0.3.*" "langchain-community==0.3.*"
# -*- coding: utf-8 -*-
"""示例:Function Calling 最小循环 —— 模型负责"决策",你的代码负责"执行"。

演示链路:
    用户提问 -> 模型输出 tool_calls(调用指令)
    -> 宿主代码执行真实函数(等价于调你的 Service 方法)
    -> 执行结果以 ToolMessage 回填给模型
    -> 模型基于真实结果给出最终回答

运行前设置环境变量:
    export OPENAI_API_KEY=sk-xxx
    # 可选:使用 OpenAI 兼容端点(国内可替换):
    #   DeepSeek:     export OPENAI_BASE_URL=https://api.deepseek.com/v1
    #   阿里云百炼:   export OPENAI_BASE_URL=https://dashscope.aliyuncs.com/compatible-mode/v1
"""

import json
import os
from typing import Any

from langchain_core.messages import (
    AIMessage,
    HumanMessage,
    SystemMessage,
    ToolMessage,
)
from langchain_openai import ChatOpenAI

llm = ChatOpenAI(
    model="gpt-4o-mini",
    api_key=os.getenv("OPENAI_API_KEY"),
    base_url=os.getenv("OPENAI_BASE_URL"),  # 未设置则默认 OpenAI 官方
    temperature=0.0,  # 工具调用场景要确定性,尽量低
)


# ---------- 1. 工具:就是一段普通代码,当成 Service 方法写就行 ----------
def get_flight_status(flight_no: str, date: str = "2026-09-21") -> dict[str, Any]:
    """查询航班状态。真实项目里这里会去查离港系统/第三方接口/数据库。"""
    table = {
        "CA1234": {"status": "DELAYED", "delay_minutes": 45, "gate": "C12"},
        "MU5121": {"status": "ON_TIME", "gate": "B07"},
    }
    flight = table.get(flight_no)
    if not flight:
        return {"flight_no": flight_no, "date": date, "status": "UNKNOWN"}
    return {"flight_no": flight_no, "date": date, **flight}


# 工具注册表:名字 -> 可调用对象。等价于一份"路由表",真实项目可用工厂/注解扫描
TOOL_REGISTRY: dict[str, Any] = {
    "get_flight_status": get_flight_status,
}

# 工具对模型的"说明书":OpenAI Function Calling 原生 JSON Schema 格式
# 这里手写以便看清结构;也可以用 langchain 的 @tool 装饰器自动生成
TOOL_SCHEMAS: list[dict] = [
    {
        "type": "function",
        "function": {
            "name": "get_flight_status",
            "description": "根据航班号和日期查询航班状态(准点/延误/取消)",
            "parameters": {
                "type": "object",
                "properties": {
                    "flight_no": {
                        "type": "string",
                        "description": "航班号,例如 CA1234",
                    },
                    "date": {
                        "type": "string",
                        "description": "日期,格式 YYYY-MM-DD,默认今天",
                    },
                },
                "required": ["flight_no"],
            },
        },
    }
]


def execute_tool(name: str, args: dict[str, Any]) -> dict[str, Any]:
    """执行工具。等价于 Java 里按名字路由到 Service 方法(反射/Map 分发)。"""
    fn = TOOL_REGISTRY[name]
    return fn(**args)


def main() -> None:
    # 消息历史 = "会话状态",必须由我们自己维护并显式传给每次请求
    messages: list = [
        SystemMessage(
            content="你是航空出行助手。查航班状态必须调用 get_flight_status "
            "工具,不要自己编造航班状态。"
        ),
        HumanMessage(content="帮我查一下 CA1234 明天是否准点?"),
    ]

    # 循环上限是 Agent 工程第一条安全红线:防止模型失控死循环、token 成本爆炸
    MAX_ROUNDS = 5
    model_with_tools = llm.bind_tools(TOOL_SCHEMAS)

    for _ in range(MAX_ROUNDS):
        ai: AIMessage = model_with_tools.invoke(messages)
        messages.append(ai)

        if not ai.tool_calls:
            # 模型没有要求调用工具 -> 输出最终回答,循环结束
            print("最终回答:", ai.content)
            return

        # 模型决定调用工具。先看它"想"调用什么(演示归一化后的原始结构)
        print("模型输出的 tool_calls 原始结构:")
        print(json.dumps(ai.tool_calls, ensure_ascii=False, indent=2))

        # 宿主代码真正执行,然后逐条把结果回填给模型
        for call in ai.tool_calls:
            result = execute_tool(call["name"], call["args"])
            print(f"执行工具 {call['name']}({call['args']}) -> {result}")
            messages.append(
                ToolMessage(
                    content=json.dumps(result, ensure_ascii=False),
                    tool_call_id=call["id"],  # 必须回传该 id,模型靠它对齐调用
                )
            )

    print("达到最大轮数仍未收敛,请人工介入排查。")


if __name__ == "__main__":
    main()

把这段代码跑通,你就已经掌握了一个最简 Agent 的核心循环。运行一次后建议你对照输出看:第一次请求模型返回了什么(大概率是 tool_calls),第二次请求时模型拿到了什么(工具结果),最后它是怎么组织成一句人话的。

5.3 Function Calling 的常见坑与防御

坑现象防御
模型调用了 schema 外的工具/参数execute_tool 抛 KeyError,进程崩溃宿主侧白名单校验;把"未知工具"的友好错误作为 ToolMessage 回填,让模型换一种做法
参数格式不合法日期写成 2026/09/21、缺必填字段工具函数内做参数校验与默认值,返回"参数不合法"结果,而不是抛异常炸掉整条链路
死循环 / 原地打转模型反复调用同一个工具不收敛轮次上限 + 每轮把"已经调用过的动作"拼进上下文做提示
工具结果太长结果文本挤占上下文窗口,原始问题被"挤出"注意力结果做摘要/只返回关键字段;长结果落库、只回填引用
工具执行抛异常整个对话链路中断捕获异常,把异常信息转成 ToolMessage 回填("查询失败,原因:…"),让模型重试或如实告知用户

核心心法:把工具调用当作 RPC 的被调方来建设——参数校验、白名单、超时、限流、重试、日志全都要有;而"调用方"是概率模型,它的输入天然不可信,你的服务端契约是最后防线。这条规则从本章起贯穿整套教程。

6. 什么是 Agent

6.1 定义:以 LLM 为决策核心的闭环

Agent(智能体)不是"会聊天的接口",而是一个以 LLM 为决策大脑的循环系统,学术源头是 2022 年的 ReAct 论文(Reasoning + Acting,即"先想一步再做一步")。标准形态是感知—决策—行动—反思四步循环:

flowchart LR
    P["感知 Observe<br/>读取用户输入 / 工具结果 / 环境状态"] --> D["决策 Decide<br/>LLM 基于当前上下文<br/>选择下一步动作"]
    D --> A["行动 Act<br/>调用工具 / 输出文本"]
    A --> R["反思 Reflect<br/>评估结果是否达成目标"]
    R -->|"未完成,且轮次未超限"| D
    R -->|"已完成 / 超过轮次上限 / 人工打断"| E["结束"]

你完全可以把它看成一个状态机:状态 = 消息历史 + 各次工具调用的结果;转移 = 由 LLM 的"决策"驱动,而不是由写死的代码驱动。这也是为什么第 2 章之后我们会用 LangGraph(状态图)来做正式实现——图模型就是这个循环的工程化表达。

6.2 三种形态对比:普通调用 / 固定 Workflow / Agent

维度普通 LLM 调用固定 Workflow(Pipeline)Agent
决策者无(一次性问答)预写的代码与规则LLM 在每一步做决策
流程固定单次固定顺序/固定分支动态展开,视情况而定
状态/上下文无状态局部变量/显式传参消息历史 + 记忆管理
能力边界只会"说话"流程内固定动作可自由组合多个工具
成本与风险低中(完全可控)高(Token 波动、可能失控)
可测试性高高低,需要专门的观测与回放手段
典型场景问答、翻译、摘要审批流、定时任务、固定解析多步任务、需要动态拆解的任务

工程判断(务必记住):

能用固定 Workflow 解决的,不要用 Agent。 Agent 是用"确定性换来灵活性"的交换,它带来的是动态能力,同时也带来成本不可预估、行为不可复现、出错链路难定位三座大山。合理形态是:外层固定(调度、鉴权、审批、配额),内层动态(LLM 决定怎么拆解与调用工具)。

Agent 的经典失败模式与防御清单:

  • Token 成本爆炸:自主循环每转一圈就是一次完整请求。防御:轮次上限、单轮 max_tokens 上限、总额度预算(可中途止损)。
  • 死循环/原地打转:模型反复调用同一工具。防御:轮次上限 + 记录每轮动作去重提示。
  • 目标漂移:任务拆着拆着偏离了用户意图。防御:把任务边界和终止条件写进 System Prompt,明确"什么时候算完成"。
  • 不可复现:同样的输入,两次输出不同。防御:记录全部消息(含每个工具调用的输入输出),出现问题时整体重放。

7. 框架地图与早期教训

7.1 主流框架定位

框架语言定位对应你熟悉的东西
LangChain(0.3.x)Python组件库:模型封装、Prompt、输出解析、工具、检索、Chain一个工具类集合 + 少量编排原语
LangGraph(0.4.x)Python把 Agent 建成状态图:节点=步骤,边=转移,内置循环控制、断点、checkpoint、人工介入状态机 / 工作流引擎(如 Spring StateMachine)
LangChain4jJavaLangChain 思想的 Java 实现,组件化接入各家模型Java 版组件库
Spring AIJavaSpring 官方出品,深度整合 Spring Boot(autoconfigure、可观测性)把 LLM 能力当作 Spring Bean 接入

经验之谈(不是教条):

  • LangChain 的优势是组件全、起步快,劣势是抽象多、API 变动频繁——别把业务逻辑依赖在它的内部细节上,只依赖它的稳定接口(如 invoke、Message、Tool)。
  • 复杂 Agent 官方推荐用 LangGraph:它把"循环"、"上限"、"分支"、"人工打断"这些 Agent 的工程难点变成图结构的第一等公民,而不是在回调里手搓。
  • Java 侧的真实工程约束是运维体系与模型厂商兼容性:国内项目常直接对接 DeepSeek / Qwen / GLM 等 OpenAI 兼容端点(ChatOpenAI(base_url=...) 即可切),与框架本身无关。
  • 框架选型的本质不是"哪个更好",而是"谁来替你做上下文管理、循环控制、工具注册、观测"。

7.2 AutoGPT 的教训:为什么"完全自主"不可取

2023 年 3 月开源的 AutoGPT 是早期"自主 Agent"的代表:给它一个目标,让它自己拆解、自己调用工具、自己持续行动直到完成。它一度火爆,但很快暴露出问题:

  • 不可控:自主循环一旦偏离轨道,很难打断或归因,几小时跑出完全无法理解的中间产物。
  • 成本爆炸:长循环 × 大上下文 × 每轮计费,一个"简单任务"可能烧掉大量 Token。
  • 目标漂移:模型在长循环里逐渐偏离初始目标,越修越远。
  • 难调试:把"意图理解 + 规划 + 工具 + 记忆"全揉在一个循环里,失败时定位不了是哪一环错了。

Agent 工程的第一原则由此确立:可终止、可观测、有成本上限。 当前工业界落地的形态几乎都是"人类监督 + 有限步循环 + 每个决策可回放",而不是"无人值守的自主 AI"。后面各章的所有工程手段(轮次上限、checkpoint、人工确认、日志回放、图结构)都是在执行这一原则。

8. 术语表

术语一句话解释Java 类比
TokenLLM 输入输出的最小计量单位,也是计费单位报文里的词元
Context Window模型一次能看到的 Token 总量,输入输出共享请求体大小上限
System Prompt随每次请求注入的角色与规则说明常量契约模板(模型不保证遵守)
Temperature / top_p控制生成随机性的采样参数请求参数,无 Java 对应物
max_tokens单次输出 Token 上限响应大小上限 + 超时保护
Embedding把文本映射为向量,语义相近则向量相近特征向量编码器
RAG检索增强生成:先从向量库检索相关资料,拼进 Prompt 再让模型作答Service 先查库、再拼 DTO 调下游
Tool / Function Calling模型输出结构化调用指令,宿主代码执行并回填结果RPC:模型是调用方,JSON Schema 是 IDL
Agent以 LLM 为决策核心的感知-决策-行动-反思循环由 LLM 驱动转移的状态机
Multi-Agent多个 Agent 分工协作(编排者 + 多个子 Agent)多个微服务之间的编排
MCPModel Context Protocol:统一"模型发现并调用外部工具"的开放协议给工具接入定一套标准接口(类似 JDBC 之于数据库)
ReActReasoning + Acting 模式:先推理再行动,再依据结果继续推理决策循环的设计模式
Zero-shot / Few-shot不举例 / 给几个示例让模型模仿完成任务不改代码,靠入参示例切换行为

关于 MCP 多说两句(第 7 章会详解):它是 2024 年底由 Anthropic 提出的开放协议,2025 年进入 Linux 基金会托管(以官方公告为准)。它解决的问题是"每个框架、每个 Agent 都要自己发明一套工具接入格式"。如果你把 Function Calling 比作一次 RPC,那 MCP 就是给"工具/资源/上下文的发现与接入"定了一套公共接口标准——一个模型可以像接数据库驱动一样接入不同平台的服务。

小结

  • LLM = 无状态概率接口 + 自回归逐 Token 续写。Token 计量输入输出,上下文窗口限制总量,temperature / top_p / max_tokens 控制单次生成行为。
  • 幻觉与算错是自回归续写的结构性结果:它猜"最像什么",不算"是什么"。精确环节必须交给代码、数据库与工具。
  • 无状态意味着上下文管理必须由调用方显式完成——这是 Agent 工程一切复杂性的源头。
  • Function Calling 是"模型决策 + 宿主执行"的 RPC 往返:schema 描述、结构化 tool_calls、宿主执行、结果按 tool_call_id 回填、模型继续。第 5.2 节的最小循环就是 Agent 的雏形。
  • Agent 是用确定性换灵活性,核心是感知-决策-行动-反思闭环;能用固定 Workflow 解决的就不要上 Agent;"可终止、可观测、成本上限"是第一原则。
  • 框架上:LangChain 管组件,LangGraph 管有状态循环,LangChain4j / Spring AI 服务 Java 生态。早期 AutoGPT 的教训是"完全自主"不可控。

练习

  1. 复现无状态性:把第 5.2 节代码里的 SystemMessage 内容改成一个"多轮对话"任务(如先问航班、再问"刚才那个航班延误多久"),观察不携带历史时模型如何答错。然后手动把第一轮的回答作为消息拼进第二轮,验证"显式传上下文"是唯一正确姿势。
  2. 加一个工具:仿照 get_flight_status,实现 cancel_flight(booking_no: str),注册进 TOOL_REGISTRY 和 TOOL_SCHEMAS,让模型回答"帮我把 CA1234 退了"。观察模型是否能正确调用两个工具、在结果之上继续作答。
  3. 调参数看差异:把 temperature 分别设为 0 和 1.5,各问 5 次同一个开放问题(如"推荐三个周末目的地"),统计回答的多样性。把结论写进笔记:什么场景需要低温度。
  4. 防御练习:故意让模型调用一个 schema 里没有的工具名(在 System Prompt 里诱导它"用 get_weather 查天气"),看你的 execute_tool 会不会抛 KeyError。加上对未知工具名的友好报错,并把错误信息回填给模型让它换一种做法。
  5. 读代码:把第 5.2 节的循环手画成状态图(参照 6.1 节),标出"每一步的状态是什么、终止条件在哪、成本上限在哪"。

参考答案

<details>
<summary>练习1(复现无状态性)</summary>

把 SystemMessage 改成多轮任务后,第二轮如果不携带历史,模型会"不知道刚才那个航班是谁",可能直接编一个或回答"不清楚"。验证代码:

# 第一轮正常
r1 = model.invoke([SystemMessage(content="你是航班助手"),
                   HumanMessage(content="查 CA1501 的状态")])

# 第二轮:不带历史 → 答错或答不出
r2_wrong = model.invoke([HumanMessage(content="刚才那个航班延误多久?")])

# 第二轮:显式拼上第一轮的回答 → 正确
r2_right = model.invoke([SystemMessage(content="你是航班助手"),
                         HumanMessage(content="查 CA1501 的状态"),
                         r1,                                   # 把第一轮的 AIMessage 拼进来
                         HumanMessage(content="刚才那个航班延误多久?")])

关键结论:LLM 本身无状态,"记忆"就是"把之前的消息显式传给模型"。这就是后续所有框架(LangChain 的 messages、LangGraph 的 State/Checkpointer)在做的事。
</details>

<details>
<summary>练习2(加一个 cancel_flight 工具)</summary>

定义工具并注册进注册表:

@tool
def cancel_flight(booking_no: str) -> str:
    """取消指定预订。

    Args:
        booking_no: 预订号,如 BK001
    """
    return f"订单 {booking_no} 已取消"

TOOLS = [get_flight_status, cancel_flight]
TOOL_MAP = {t.name: t for t in TOOLS}
TOOL_SCHEMAS = [t.to_openai_function() if hasattr(t, "to_openai_function") else t for t in TOOLS]

问"帮我把 CA1234 退了"时,模型很可能先调 get_flight_status 确认航班存在,再调 cancel_flight 取消,最后汇总。观察点:

  • 模型能一次对话里连续调用两个工具(第一轮工具结果回填后继续决策);
  • 若 cancel_flight 需要的是预订号而用户只给了航班号,模型会追问用户或在消息里推断——工具参数描述(docstring)写得越清楚,这一步越稳。
    </details>

<details>
<summary>练习3(temperature 0 vs 1.5)</summary>

for t in (0, 1.5):
    outputs = {model.invoke(...) for _ in range(5)}   # 问同一个开放问题
    print(t, "不同回答数:", len(outputs))

预期结果:

  • temperature=0:5 次回答几乎一致(差异小);
  • temperature=1.5:5 次回答明显多样。

结论:事实/提取/客服类回答用低温度(0~0.3),追求确定性与格式稳定;创意/头脑风暴用高温度(0.7~1.5)。注意:结构化输出(with_structured_output)务必配低温度,否则 JSON 内容波动大。
</details>

<details>
<summary>练习4(防御:未知工具名)</summary>

在 System Prompt 里写"如需天气,用 get_weather 查询",模型会真的输出 tool_calls: name=get_weather。如果你的 execute_tool 是 TOOL_REGISTRY[name](...),就会抛 KeyError。修复成"兜底 + 回填":

func = TOOL_MAP.get(name)
if func is None:
    result = f"抱歉,我不认识工具 {name},请换一种方式处理。"
else:
    result = func(**args)
messages.append(ToolMessage(content=result, tool_call_id=call_id))

把错误信息回填后,模型会"自己转弯"(道歉并改用已有工具,或直接基于知识回答)。这是 Agent 工程的标准防御:工具层对未知/异常一律友好兜底,并作为"工具结果"回传,让模型自行纠错(第 12 章还会讲更严格的权限控制)。
</details>

<details>
<summary>练习5(把循环画成状态图)</summary>

           ┌────────────────────────────────────────────┐
           │                                            │
           ▼                                            │
[State: messages = [system, user, ...]]                 │
           │                                            │
           ▼                                            │
      模型推理(model_with_tools) ──有 tool_calls──► 执行工具(execute_tool)
           │                                            │
           │ 无 tool_calls                               │
           ▼                                            │
      输出最终答案                                       │
           │                                            │
           ▼                                            │
        终止                                            │
           └──── 状态追加:AIMessage + ToolMessage ◄────┘

标出三件事:

  • 每一步的状态:就是 messages 列表本身(不断追加新消息);
  • 终止条件:模型某轮没有 tool_calls(或达到 max_iterations);
  • 成本上限:max_iterations × 每轮(token 数)——每轮都要把全部历史重发,历史越长成本越高。

这张图和第 10 章 LangGraph 的 agent ⇄ tools 图完全同构——你已经在徒手实现 LangGraph 了。
</details>

延伸阅读