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、阿里云百炼 Qwenhttps://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 Calling | RPC 协议(接口定义 + 参数序列化) | 发起调用的"客户端"是模型,契约格式是 JSON Schema |
| Embedding | 文本到特征向量的编码器 | 输出可做语义相似度检索 |
| RAG | Service 层先查数据库、再拼 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) |
| LangChain4j | Java | LangChain 思想的 Java 实现,组件化接入各家模型 | Java 版组件库 |
| Spring AI | Java | Spring 官方出品,深度整合 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 类比 |
|---|---|---|
| Token | LLM 输入输出的最小计量单位,也是计费单位 | 报文里的词元 |
| 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) | 多个微服务之间的编排 |
| MCP | Model Context Protocol:统一"模型发现并调用外部工具"的开放协议 | 给工具接入定一套标准接口(类似 JDBC 之于数据库) |
| ReAct | Reasoning + 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 的教训是"完全自主"不可控。
练习
- 复现无状态性:把第 5.2 节代码里的
SystemMessage内容改成一个"多轮对话"任务(如先问航班、再问"刚才那个航班延误多久"),观察不携带历史时模型如何答错。然后手动把第一轮的回答作为消息拼进第二轮,验证"显式传上下文"是唯一正确姿势。 - 加一个工具:仿照
get_flight_status,实现cancel_flight(booking_no: str),注册进TOOL_REGISTRY和TOOL_SCHEMAS,让模型回答"帮我把 CA1234 退了"。观察模型是否能正确调用两个工具、在结果之上继续作答。 - 调参数看差异:把
temperature分别设为 0 和 1.5,各问 5 次同一个开放问题(如"推荐三个周末目的地"),统计回答的多样性。把结论写进笔记:什么场景需要低温度。 - 防御练习:故意让模型调用一个 schema 里没有的工具名(在 System Prompt 里诱导它"用
get_weather查天气"),看你的execute_tool会不会抛 KeyError。加上对未知工具名的友好报错,并把错误信息回填给模型让它换一种做法。 - 读代码:把第 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>
延伸阅读
- 环境准备与本书结构:./00-导读.md
- OpenAI 官方 Function Calling 文档:https://platform.openai.com/docs/guides/function-calling
- OpenAI 官方(中文可访问)模型与参数文档:https://platform.openai.com/docs/api-reference/chat/create
- LangChain(0.3.x)Python 文档:https://python.langchain.com/docs/
- LangGraph(0.4.x)文档:https://langchain-ai.github.io/langgraph/
- MCP 官方文档:https://modelcontextprotocol.io/
- ReAct 论文《ReAct: Synergizing Reasoning and Acting in Language Models》:https://arxiv.org/abs/2210.03629
- 《Lost in the Middle: How Language Models Use Long Contexts》:https://arxiv.org/abs/2307.03172

0 条评论