从零开始构建Agent

本文核心内容来源于视频:https://www.douyin.com/video/7529703060969508130, 并对其中部分内容做了修订,建议先观看视频,文字内容作为补充。

这两年大模型飞速发展,语言理解逻辑推理能力越来越强大,但你是否也发现了一个问题:尽管这些模型能“说”得非常漂亮,它们却很难“做”事情。比如,让大模型写个代码没问题,但让它保存到文件里?还的你动手,更别说部署了。这就是大模型最本质的限制——它无法直接感知和改变外部环境

接下来,我将带你深入理解如何利用工具赋能大模型,构建具备“感官”和“行动力”的智能体 ( Agent ),详细讲解 ReActPlan-and-Execute 两种主流的 Agent 设计模式,并以实例代码演示如何从0开始构建一个简单的 Agent,让你对 Agent 的构建原理了然于心。

大模型的局限:无法感知与行动

先来看一个简单例子:你让 GPT-4o 帮你写一个贪吃蛇游戏,它确实能生成一份不错的代码。但写完后呢?它无法把代码写入本地文件、也不能运行程序、更不会自己浏览项目目录。哪怕你已经有了一些代码,想让它“接着改”,也只能手动复制粘贴。

换句话说,大模型是“盲”和“瘫”的,它不知道外部世界发生了什么,也无法改变它,除非你主动告诉它一切。

Build effective agents

原文:Building effective agents

在过去一年中,我们与各行各业数十个团队合作,帮助他们基于大型语言模型 ( LLM ) 构建 Agent。我们发现,最成功的实现并不依赖复杂的框架或专业化的库,而是使用了简单且可组合的模式。

在本文中,我们将分享与客户合作以及我们自身构建 Agent 时所积累的经验,并为开发者提供一些构建高效 Agent 的实用建议。

如何做技术选型?

曾经在文章中说过一句话,大意是说判断架构的牛逼与否,不是看它有多复杂,而是看它在解决同样问题时有多简单。有同学回复说,牛逼的架构是靠时间验证,跟当时解决问题的复杂和简单没啥关系。这话当然没有问题,任何牛逼的架构都需要时间来验证,但我的本意其实是说在做架构设计时,应当尽量追求简单,坚决砍掉一些可有可无的设计。

说句题外话,身边的工程师朋友比较多,所以倒闭的公司看到的也挺多。很多公司一上来就高并发、分布式、分库分表,但真的,到最后死的时候,数据也没能把一张 MySQL 表装满。可能是看得多了的缘故,不管是做架构还是写代码,就特别推崇 KISS 原则,所以才有了前面的那些话,但这仅是个人感悟,绝非通用的设计原则。

封面图 Photo by Daria Nepriakhina

作为架构设计的重要环节,技术选型的思路和原则应当与我们之前所学一样。那如何为业务场景选择适合的实现技术?又如何构建自己的技术选型方法论?

深入理解JVM运行时内存结构(续)

JVM 以方法作为执行的基本单位,栈帧(Stack Frame)则是用于支持 JVM 进行方法调用和执行的数据结构。每个方法在执行的同时都会创建一个栈帧用于存储方法的局部变量表、操作数栈、动态连接、方法返回地址和一些额外的附加信息。每一个方法从调用开始至执行结束,都对应着一个栈帧在虚拟机栈里面从入栈到出栈的过程。当方法调用结束,栈帧即被销毁,局部变量表、操作数栈等也随之消失。

Java 代码在编译时就已经计算出栈帧需要多大的局部变量表,需要多深的操作数栈,并把它们写入到方法表的 Code 属性之中。换句话说,一个栈帧需要分配多少内存,在编译时已经确定,并不会受到程序运行期间变量数据的影响,仅取决于源码和具体的虚拟机是如何实现栈帧的。

深入理解 JVM 对象模型

深入理解JVM运行时内存结构 中,我们宏观地探讨了 JVM 运行时的内存布局。本文将深入分析每个内存区域的细节。由于不同虚拟机的内存管理实现不同,具体的讨论需要聚焦于特定虚拟机和内存区域。基于实用优先的原则,我们以最常用的 HotSpot 虚拟机和 Java 堆内存为例,深入探讨在 HotSpot 虚拟机里:

  1. 对象在堆内存中是如何布局的?
  2. JVM 是如何实现 Java 对象?
  3. 对象是如何创建出来的?

对象的内存布局

在虚拟机里,对象由三部分构成,分别是 对象头实例数据对齐填充。对象头的结构复杂,下面会详细介绍。实例数据是对象真正存储的有效信息,包含所有我们在代码中定义的各种类型的字段的内容,无论是继承自父类还是在子类中定义的。最后是对齐填充,由于HotSpot虚拟机的自动内存管理系统要求对象起始地址必须是8字节的整数倍,换句话说就是任何对象的大小都必须是8字节的整数倍。对象头部分已经被精心设计成正好是8字节的倍数,如果对象实例数据部分没有对齐的话,就需要通过对齐填充来补全。

接下来,我们详细说说对象头。HotSpot 虚拟机对象的对象头由三部分构成,分别是存储对象自身运行时数据的 Mark Word、指向类型元数据的指针、当对象是数组时,记录数组长度的 length。在 64 位系统中,对象头的大小是 16 个字节,可以通过指针压缩的方式,压缩到 12 个字节。当 JVM 中存在大量对象的时候,通过指针压缩减少对象内存占用是一个提升性能的手段。

需要注意的是,并不是所有虚拟机在实现对象时,都会在对象头中保留类型指针,还可以通过其它方式来查找对象类型的元数据信息,这点会在最后的「对象的访问」小节中补充讨论。

深入理解 JVM 垃圾回收机制 - 何为垃圾?

随着编程语言的发展,GC 的功能不断增强,性能也不断提高,作为语言背后的无名英雄,GC 离我们的工作似乎越来越远。作为 Java 程序员,对这一点也许会有更深的体会,我们不需要了解太多与 GC 相关的知识,就能很好的完成工作。那还有必要深入了解 GC 吗?学习 GC 的意义在哪儿?

何为垃圾题图

不管性能提高到何种程度,GC 都需要花费一定的时间,对于实时性要求较高的场景,就必须尽量压低 GC 导致的最大暂停时间 (GC 会导致应用线程处于暂停状态),举两个例子:

  • 实时对战游戏:如果因为 GC 导致玩家频繁卡顿,任谁都会想摔手机吧。
  • 金融交易:在某些对价格非常敏感的交易(比如:外汇)场景下,如果因为 GC 导致没有按照投资者指定的价格进行交易,相信我,这些投资者非生吃了你。

但也有许多场景,GC 的最大暂停时间没那么重要,比如,离线分析、视频网站等等。因此,知道 这个 GC 算法有这样的特征,所以它适合这个场景,对程序员来说非常有价值,这也是我们学习 GC 最重要的意义。

接下来,我们将一步步走进 GC 的世界。

深入理解JVM运行时内存结构

得益于 JVM 的自动内存管理机制,开发者在写代码时,很少再去关注内存分配与释放。多数情况下,应用不会出现内存泄漏和溢出问题。不过,由于开发者把内存的控制权交给了 JVM,一旦出现内存泄露和溢出问题,如果不了解 JVM 是怎样使用内存的,将很难排查和修正错误。本文从概念上介绍 JVM 运行时内存的各个区域及其作用。

JVM 在执行程序时会把其所管理的内存划分成多个不同的数据区域,每个区域的创建时间、销毁时间以及用途都各不相同。比如有的内存区域是所有线程共享的,而有的内存区域是线程隔离的。线程隔离的区域就会随着线程的启动和结束而创建和销毁。JVM 所管理的内存将会包含以下几个运行时数据区域,如下图所示。
JVM运行时内存数据区示意图

深入理解JVM常量池与字节码

在 JVM 中,常量池可以分成 Class 文件常量池、运行时常量池、字符串常量池三类。

Class 文件常量池

Java 源文件经编译后得到存储字节码的 Class 文件,Class 文件是一组以 8 位字节为基础单位的二进制流,各个数据项目严格按照顺序紧凑地排列在 Class 文件中。也就是说,哪个字节代表什么含义,长度多少,先后顺序如何都是被严格限定的,是不允许改变的。比如:开头的 4 个字节存放魔数,用于确定这个文件是否能够被 JVM 接受,接下来的 4 个字节用于存放版本号,再接着存放的就是常量池。常量池的长度是不固定的,所以,在常量池的入口存放着常量池容量的计数值。

常量池主要用于存放两大类常量:字面量和符号引用量,字面量相当于 Java 语言层面常量的概念,比如:字符串常量、声明为 final 的常量等等。符号引用是用一组符号来描述所引用的目标,符号可以是任何形式的字面量,只要使用时能无歧义的定位到目标即可。

深入理解 ContextClassLoader

类的加载器大多都遵循双亲委托机制,它不仅可以避免一个类被重复加载,也能够避免 Java 核心类被篡改,越基础的类由越上层的类加载器加载。但程序设计往往没有绝对不变的规则,如果有核心类想要调用用户的代码,那该怎么办?

深入理解 ClassLoader

ClassLoader 源码初探

类加载器用于加载 Java 类到虚拟机中,其实现类 java.lang.ClassLoader 是一个抽象类,其职责是通过指定类的完全限定名 ( binary name ),找到或生成这个类对应的字节码,这些字节码中包含类的定义数据,通过字节码就可以构造出一个 java.lang.Class 对象。

每个 Class 对象都包含一个定义它的类加载器的引用,而数组的 Class 对象不是用类加载器创建的,而是在 Java 运行时根据需要自动创建的,如果调用数组的 Class 对象的 getClassLoader() 方法返回的类加载器与其元素类型的类加载器相同,如果数组元素是基本数据类,则没有类加载,返回空。比如:

// C为自定义类
C[] cs = new C[2];
// jdk.internal.loader.ClassLoaders$AppClassLoader@3d4eac69
System.out.println(cs.getClass().getClassLoader());

int[] is = new int[2];
// null
System.out.println(is.getClass().getClassLoader());