+4

Toàn tập về LangGraph.js (Phần 1)

Chào mừng bạn đến với LangGraph.js, một thư viện JavaScript được thiết kế để xây dựng các AI agent phức tạp, có thể mở rộng bằng cách sử dụng máy trạng thái dựa trên đồ thị. Trong bài viết này, chúng ta sẽ khám phá các khái niệm cốt lõi đằng sau LangGraph.js và lý do tại sao nó lại vô cùng vượt trội trong việc tạo ra các hệ thống agent đáng tin cậy và có khả năng chịu lỗi.

Bối cảnh: Agents as Graph và AI Workflow

Trong khi có khá nhiều định nghĩa về "AI agents" khác nhau, tôi có thể nói cơ bản định nghĩa về "agent", đó là bất kỳ hệ thống nào cho phép mô hình ngôn ngữ kiểm soát các quy trình làm việc lặp lại và thực hiện hành động đều sẽ được gọi là agents. Các LLM agents điển hình sử dụng thiết kế theo phong cách "Lý luận và Hành động" (ReAct), áp dụng LLM để điều khiển một vòng lặp cơ bản bao gồm các bước sau:

  1. Lý do và lập kế hoạch hành động cần thực hiện.
  2. Thực hiện hành động bằng các công cụ (chức năng phần mềm thông thường).
  3. Quan sát tác động của các công cụ và lập kế hoạch lại hoặc phản ứng phù hợp.

Trong khi các LLM agents hoạt động tốt về mặt này, các vòng lặp agents đơn điệu sẽ không thể cung cấp độ tin cậy mà người dùng mong đợi ở quy mô lớn. Chúng có tính ngẫu nhiên tuyệt vời, các hệ thống được thiết kế tốt có thể khai thác tính ngẫu nhiên này và áp dụng nó một cách hợp lý trong khi các hệ thống tổng hợp được thiết kế tốt, khiến chúng có khả năng chịu lỗi đối với các lỗi trong đầu ra LLM vì lỗi sẽ xảy ra thường xuyên.

Tôi tin rằng các agents rất thú vị và mới lạ, nhưng các mẫu thiết kế AI nên được áp dụng bằng các phương pháp kỹ thuật tốt từ Software 2.0. Một số điểm tương đồng bao gồm:

  1. Các ứng dụng AI phải cân bằng giữa hoạt động tự động với khả năng kiểm soát của người dùng.
  2. Các ứng dụng tác nhân tương tự như các hệ thống phân tán về khả năng chịu lỗi và sửa lỗi.
  3. Hệ thống đa tác nhân giống với các ứng dụng mạng nhiều người chơi ở tính song song và giải quyết xung đột.
  4. Mọi người đều thích nút hoàn tác và kiểm soát phiên bản.

Lớp trừu tượng StateGraph chính của LangGraph.js được thiết kế để hỗ trợ những nhu cầu này và các nhu cầu khác, cung cấp API cấp thấp hơn so với các khuôn khổ tác nhân khác (như AgentExecutor của LangChain), giúp bạn kiểm soát hoàn toàn cách thức và vị trí áp dụng "AI".

Nó mở rộng khuôn khổ xử lý đồ thị Pregel của Google, cung cấp khả năng chịu lỗi và phục hồi cho khối lượng công việc chạy lâu hoặc dễ xảy ra lỗi. Trong quá trình phát triển, bạn có thể tập trung vào các hoạt động cục bộ hoặc các agents tác vụ cụ thể và hệ thống sẽ lắp ráp các hoạt động này thành một ứng dụng mạnh mẽ và có khả năng mở rộng hơn.

Tính năng song song và giảm trạng thái cho phép bạn kiểm soát cách xử lý thông tin xung đột được trả về bởi nhiều agents. Cuối cùng, hệ thống điểm kiểm tra phiên bản liên tục cho phép bạn khôi phục trạng thái agents, khám phá các đường dẫn thay thế và kiểm soát hoàn toàn những gì đang diễn ra.

Các phần sau sẽ đi sâu vào cách thức hoạt động và lý do của những khái niệm này.

Thiết kế cốt lõi

Về bản chất, LangGraph.js mô hình hóa quy trình làm việc của các agents như một State machine. Bạn có thể xác định hành vi của agents bằng ba thành phần chính:

  1. State : Cấu trúc dữ liệu dùng chung biểu diễn ảnh chụp nhanh State hiện tại của ứng dụng. Nó có thể là bất kỳ loại TypeScript nào nhưng thường là giao diện hoặc lớp.
  2. Nodes : Các hàm TypeScript mã hóa logic của agents. Chúng lấy State hiện tại làm đầu vào, thực hiện một số tính toán hoặc hiệu ứng phụ và trả về State đã cập nhật.
  3. Edges : Quy tắc luồng điều khiển xác định Node nào sẽ thực thi tiếp theo dựa trên State hiện tại. Chúng có thể là các nhánh có điều kiện hoặc các chuyển tiếp cố định. Bằng cách kết hợp Nodes và Edges, bạn có thể tạo ra các quy trình làm việc vòng lặp phức tạp phát triển State theo thời gian. Sức mạnh thực sự nằm ở cách LangGraph.js quản lý các States này.

Tóm lại: Các Nodes thì làm công việc của mình; còn các Edges sẽ quyết định việc gì cần làm tiếp theo.

Thuật toán đồ thị cơ bản của LangGraph.js định nghĩa một chương trình chung sử dụng việc truyền tin nhắn. Khi một Node hoàn tất, nó sẽ gửi tin nhắn (State) dọc theo một hoặc nhiều Edges đến các Node khác. Các Node này chạy các hàm của chúng và truyền các tin nhắn kết quả đến tập hợp các Node tiếp theo, v.v. Lấy cảm hứng từ Pregel, chương trình thực thi theo các "siêu bước" rời rạc song song về mặt khái niệm. Khi đồ thị chạy, tất cả các Node bắt đầu ở trạng thái không hoạt động. Khi một Edge đến (hoặc "channel") nhận được một tin nhắn mới (State), Node sẽ trở nên hoạt động, chạy chức năng của nó và phản hồi bằng các bản cập nhật. Vào cuối mỗi siêu bước, nếu không còn tin nhắn Edge đến nào nữa, các Node sẽ bỏ phiếu dừng lại, tự đánh dấu là không hoạt động. Khi tất cả các Node không hoạt động và không có tin nhắn nào đang được truyền đi, Graph sẽ kết thúc.

Tôi sẽ minh họa cách thực thi StateGraph hoàn chỉnh sau, nhưng trước tiên, hãy cùng tìm hiểu sâu một chút về các khái niệm này nhé.

Nodes

Trong StateGraph, các Node thường là các hàm TypeScript (đồng bộ hoặc không đồng bộ), với đối số vị trí đầu tiên là State và đối số vị trí thứ hai tùy chọn là RunnableConfig, chứa các tham số có thể cấu hình tùy chọn (ví dụ: thread_id).

Tương tự như NetworkX, bạn có thể thêm các Node này vào biểu đồ bằng phương thức addNode:

Ví dụ minh họa:

import { END, START, StateGraph, StateGraphArgs } from "@langchain/langgraph";
import { RunnableConfig } from "@langchain/core/runnables";

interface IState {
  input: string;
  results?: string;
}

// This defines the agent state
const graphState: StateGraphArgs<IState>["channels"] = {
  input: {
    value: (x?: string, y?: string) => y ?? x ?? "",
    default: () => "",
  },
  results: {
    value: (x?: string, y?: string) => y ?? x ?? "",
    default: () => "",
  },
};

function myNode(state: IState, config?: RunnableConfig) {
  console.log("In node:", config?.configurable?.user_id);
  return { results: `Hello, ${state.input}!` };
}

// The second parameter is optional
function myOtherNode(state: IState) {
  return state;
}

const builder = new StateGraph({ channels: graphState })
  .addNode("my_node", myNode)
  .addNode("other_node", myOtherNode)
  .addEdge(START, "my_node")
  .addEdge("my_node", "other_node")
  .addEdge("other_node", END);

const graph = builder.compile();

const result1 = await graph.invoke(
  { input: "Will" },
  { configurable: { user_id: "abcd-123" } }
);

// In node: abcd-123
console.log(result1);
// { input: 'Will', results: 'Hello, Will!' }

Về cơ bản, các hàm được chuyển đổi thành RunnableToLambda, bổ sung hỗ trợ xử lý hàng loạt và bất đồng bộ cho các hàm của bạn, cùng với tính năng theo dõi và gỡ lỗi gốc.

Edges

Các Edge xác định cách logic định tuyến và thời điểm Graph quyết định dừng. Giống như các node, chúng lấy trạng thái Graph hiện tại và trả về một giá trị.

Theo mặc định, giá trị này là tên của Node hoặc các Node tiếp theo để gửi State tới. Tất cả các Node này sẽ chạy song song như một phần của superstep tiếp theo.

Nếu bạn muốn sử dụng lại Edge, bạn có thể tùy chọn cung cấp một từ điển ánh xạ đầu ra của Edge tới tên của Node tiếp theo.

Nếu bạn luôn muốn đi từ Node A đến Node B, bạn có thể sử dụng trực tiếp phương thức addEdge.

Nếu bạn muốn định tuyến có điều kiện đến một hoặc nhiều Edge (hoặc chấm dứt có điều kiện), bạn có thể sử dụng phương thức addConditionalEdges.

Nếu một Node có nhiều Edge đi ra, tất cả các Node mục tiêu đó sẽ chạy song song trong siêu bước tiếp theo.

State Management

LangGraph.js giới thiệu hai khái niệm chính để quản lý State: Giao diện State và Reducer.

Giao diện State xác định loại đối tượng được truyền cho mỗi Node trong Graph.

Reducer xác định cách đầu ra của Node được áp dụng cho State hiện tại. Ví dụ: bạn có thể sử dụng Reducer để hợp nhất các phản hồi hội thoại mới vào lịch sử hội thoại hoặc đầu ra trung bình từ nhiều Node của agents. Bằng cách chú thích các trường trạng thái của bạn bằng các hàm reducer, bạn có thể kiểm soát chính xác cách dữ liệu chảy qua ứng dụng của mình.

Tôi sẽ minh họa cách Reducer hoạt động bằng một ví dụ. So sánh hai States sau. Bạn có thể đoán được đầu ra trong mỗi trường hợp không?

1. Ví dụ State A

import { END, START, StateGraph } from "@langchain/langgraph";

interface StateA {
  myField: number;
}

const builderA = new StateGraph<StateA>({
  channels: {
    myField: {
      // "Override" is the default behavior:
      value: (_x: number, y: number) => y,
      default: () => 0,
    },
  },
})
  .addNode("my_node", (_state) => ({ myField: 1 }))
  .addEdge(START, "my_node")
  .addEdge("my_node", END);

const graphA = builderA.compile();

console.log(await graphA.invoke({ myField: 5 }));
// { myField: 1 }

2. Ví dụ State B

interface StateB {
  myField: number;
}

// add **reducer** defines **how** to apply state updates
// to specific fields.
function add(existing: number, updated?: number) {
  return existing + (updated ?? 0);
}

const builderB = new StateGraph<StateB>({
  channels: {
    myField: {
      value: add,
      default: () => 0,
    },
  },
})
  .addNode("my_node", (_state) => ({ myField: 1 }))
  .addEdge(START, "my_node")
  .addEdge("my_node", END);

const graphB = builderB.compile();

console.log(await graphB.invoke({ myField: 5 }));

// { myField: 6 }

Nếu bạn đoán đáp án là "1" và "6", bạn đã đúng!

Trong trường hợp đầu tiên (State A), kết quả là "1" vì Reducer mặc định của bạn là một lệnh ghi đè đơn giản. Trong trường hợp thứ hai (State B), kết quả là "6" vì tôi đã tạo một hàm add làm reducer. Hàm này lấy State hiện tại (cho trường đó) và cập nhật state (nếu được cung cấp) và trả về giá trị cập nhật cho state đó.

Các reducer thường cho Graph biết cách xử lý các bản cập nhật cho trường đó.

Khi xây dựng một chatbot đơn giản như ChatGPT, state có thể đơn giản như một danh sách các tin nhắn trò chuyện. Đây là state được sử dụng bởi MessageGraph, một trình bao bọc nhẹ xung quanh.

(Hết phần 1, cùng đón đọc phần 2 trong bài viết tiếp theo nhé)


All rights reserved

Viblo
Hãy đăng ký một tài khoản Viblo để nhận được nhiều bài viết thú vị hơn.
Đăng kí