Toàn tập về LangGraph.js (Phần 2)
Tiếp nối phần trước: Toàn tập về LangGraph.js (Phần 1) , chúng ta sẽ tiếp tục với phần 2 nhé. Một số khái niệm quan trọng khác trong LangGraph.js cần quan tâm như:
Root Reducer
import { StateGraph, END, START } from "@langchain/langgraph";
const builderE = new StateGraph({
channels: {
__root__: {
reducer: (x: string[], y?: string[]) => x.concat(y ?? []),
default: () => [],
},
},
})
.addNode("my_node", (state) => {
return [`Adding a message to ${state}`];
})
.addEdge(START, "my_node")
.addEdge("my_node", END);
const graphE = builderE.compile();
console.log(await graphE.invoke(["Hi"]));
// ["Hi", 'Added a message to Hi']
Trong ví dụ trên, việc sử dụng StateGraph sẽ phải đánh đổi một chút về thiết kế. Ví dụ, bạn có thể nghĩ rằng điều này giống như việc sử dụng các biến global một cách khá là dị (mặc dù điều này có thể được giảm thiểu bằng tham số namespace). Tuy nhiên, State được chia sẻ có các kiểu cung cấp nhiều lợi ích liên quan đến việc xây dựng quy trình làm việc AI, bao gồm:
- Luồng dữ liệu có thể được kiểm tra đầy đủ trước và sau mỗi "siêu bước".
- State có thể thay đổi, cho phép người dùng hoặc phần mềm khác ghi vào cùng một state giữa các siêu bước để kiểm soát hướng của agents (sử dụng updateState).
- Các điểm kiểm tra được xác định rõ ràng, giúp dễ dàng lưu và khôi phục hoặc thậm chí kiểm soát phiên bản hoàn toàn toàn bộ quá trình thực hiện quy trình công việc trong bất kỳ kho lưu trữ nào. Chúng ta sẽ thảo luận chi tiết hơn về các điểm kiểm tra ở phần tiếp theo.
Persistence
Bất kỳ hệ thống "thông minh" nào cũng cần bộ nhớ để hoạt động. Các tác nhân AI cũng không ngoại lệ và cần bộ nhớ trong một hoặc nhiều khung thời gian:
- Nó luôn cần phải nhớ các bước thực hiện trong nhiệm vụ này (để tránh lặp lại khi trả lời một câu hỏi nhất định).
- nó thường cần nhớ các vòng trò chuyện nhiều lượt trước đó với người dùng (để giải quyết vấn đề đồng tham chiếu và bổ sung ngữ cảnh).
- Trong điều kiện lý tưởng, nó "ghi nhớ" các tương tác trước đó với người dùng và hành vi trong một "môi trường" nhất định (ví dụ: bối cảnh ứng dụng) để hoạt động hiệu quả và cá nhân hóa hơn.
Dạng bộ nhớ sau bao gồm nhiều nội dung (cá nhân hóa, tối ưu hóa, học tập liên tục, v.v.), nằm ngoài phạm vi của bài viết này, mặc dù có thể dễ dàng tích hợp vào bất kỳ quy trình làm việc nào của LangGraph.js và tôi đang tích cực tìm ra những cách tốt nhất để thể hiện điều này một cách tự nhiên. Hai dạng bộ nhớ đầu tiên được API StateGraph hỗ trợ thông qua các Checkpoints.
Checkpoints
Checkpoint biểu thị state của một luồng trong tương tác (có khả năng) nhiều lượt giữa người dùng (hoặc người dùng hoặc các hệ thống khác). Checkpoint được tạo trong một lần chạy duy nhất sẽ có một tập hợp các Node tiếp theo để thực thi khi tiếp tục từ state đó. Checkpoint được tạo vào cuối một lần chạy nhất định là giống nhau, ngoại trừ không có Node tiếp theo nào để chuyển tiếp (biểu đồ đang chờ đầu vào của người dùng).
Checkpoint hỗ trợ bộ nhớ trò chuyện và nhiều hơn nữa, cho phép bạn đánh dấu và duy trì mọi state mà hệ thống thực hiện, cho dù trong một lần chạy hay trong nhiều tương tác.
Single Run Memory
Trong một lần chạy nhất định, các checkpoint được tạo ở mọi bước. Điều này có nghĩa là bạn có thể yêu cầu agent của mình làm bất kỳ điều gì bạn muốn. Khi nó không thành công và gặp lỗi, bạn luôn có thể khôi phục nhiệm vụ của nó từ checkpoint trước đó đã lưu.
Điều này cũng cho phép bạn xây dựng quy trình làm việc vòng lặp con người, phổ biến trong các bot hỗ trợ khách hàng, trợ lý lập trình và các ứng dụng khác. Bạn có thể ngắt quá trình thực thi của Graph trước hoặc sau khi thực thi một Node nhất định và "tăng cấp" quyền kiểm soát cho người dùng hoặc nhân viên hỗ trợ. Nhân viên có thể phản hồi ngay lập tức hoặc họ có thể phản hồi sau một tháng. Bất kể thế nào, quy trình làm việc của bạn có thể tiếp tục bất kỳ lúc nào như thể không có thời gian nào trôi qua.
Multi-Turn Memory
Các checkpoint được lưu dưới dạng thread_id để hỗ trợ tương tác nhiều vòng giữa người dùng và hệ thống. Đối với các nhà phát triển, không có sự khác biệt khi cấu hình Graph để thêm hỗ trợ bộ nhớ nhiều vòng, vì các checkpoint hoạt động theo cùng một cách trong suốt quá trình.
Nếu bạn có một số state muốn giữ lại giữa các lượt và một số state bạn muốn coi là "tạm thời", bạn có thể xóa state có liên quan trong Node cuối cùng của Graph.
Sử dụng checkpoint cũng đơn giản như gọi compile({ checkpointer: myCheckpointer }), sau đó gọi nó với thread_id trong các tham số có thể cấu hình của nó. Bạn có thể xem thêm trong phần tiếp theo!
Cấu hình
Đối với bất kỳ triển khai Graph nào, bạn có thể muốn một số giá trị có thể cấu hình được kiểm soát khi chạy. Các giá trị này khác với đầu vào Graph vì chúng không được coi là biến state. Chúng giống như giao tiếp "ngoài băng tần" hơn.
Một ví dụ phổ biến là thread_id cho một cuộc hội thoại, user_id, LLM nào sẽ sử dụng, số lượng tài liệu trả về trong trình thu thập, v.v. Mặc dù những thông tin này có thể được truyền trong state, nhưng tốt hơn là nên tách chúng khỏi luồng dữ liệu thông thường.
Ví dụ
Hãy cùng xem xét một ví dụ khác để xem bộ nhớ nhiều vòng của chúng ta hoạt động như thế nào! Bạn có thể đoán được kết quả và result2 của việc chạy biểu đồ này sẽ là gì không?
- Ví dụ 1
import { END, MemorySaver, START, StateGraph } from "@langchain/langgraph";
interface State {
total: number;
turn?: string;
}
function addF(existing: number, updated?: number) {
return existing + (updated ?? 0);
}
const builder = new StateGraph<State>({
channels: {
total: {
value: addF,
default: () => 0,
},
},
})
.addNode("add_one", (_state) => ({ total: 1 }))
.addEdge(START, "add_one")
.addEdge("add_one", END);
const memory = new MemorySaver();
const graphG = builder.compile({ checkpointer: memory });
let threadId = "some-thread";
let config = { configurable: { thread_id: threadId } };
const result = await graphG.invoke({ total: 1, turn: "First Turn" }, config);
const result2 = await graphG.invoke({ turn: "Next Turn" }, config);
const result3 = await graphG.invoke({ total: 5 }, config);
const result4 = await graphG.invoke(
{ total: 5 },
{ configurable: { thread_id: "new-thread-id" } }
);
console.log(result);
// { total: 2, turn: 'First Turn' }
console.log(result2);
// { total: 3, turn: 'Next Turn' }
console.log(result3);
// { total: 9, turn: 'Next Turn' }
console.log(result4);
// { total: 6 }
Đối với lần chạy đầu tiên, không tìm thấy checkpoint nào, do đó Grpah chạy trên đầu vào ban đầu. Tổng giá trị tăng từ 1 lên 2 và lượt được đặt thành "Lượt đầu tiên".
Đối với lần chạy thứ hai, người dùng cung cấp bản cập nhật cho "turn" nhưng không phải tổng số! Vì chúng ta tải từ state, kết quả trước đó tăng thêm một (trong Node "add_one") và "turn" bị người dùng ghi đè.
Đối với lần chạy thứ ba, "turn" vẫn không thay đổi vì nó được tải từ checkpoint nhưng không bị người dùng ghi đè. Tổng số được tăng lên theo giá trị do người dùng cung cấp vì nó cập nhật giá trị hiện có bằng hàm add.
Ở lần chạy thứ tư, tôi sử dụng một ID luồng mới và không tìm thấy checkpoint nào, do đó kết quả chỉ là tổng số do người dùng cung cấp cộng thêm một.
Bạn có thể nhận thấy rằng hành vi hướng tới người dùng này tương đương với việc chạy các lệnh sau mà không có checkpointer.
2. Ví dụ 2
const graphB = builder.compile();
const resultB1 = await graphB.invoke({ total: 1, turn: "First Turn" });
const resultB2 = await graphB.invoke({ ...result, turn: "Next Turn" });
const resultB3 = await graphB.invoke({ ...result2, total: result2.total + 5 });
const resultB4 = await graphB.invoke({ total: 5 });
console.log(resultB1);
// { total: 2, turn: 'First Turn' }
console.log(resultB2);
// { total: 3, turn: 'Next Turn' }
console.log(resultB3);
// { total: 9, turn: 'Next Turn' }
console.log(resultB4);
// { total: 6 }
Tự chạy ví dụ này để xác nhận tính tương đương. Đầu vào của người dùng và tải checkpoint được xử lý giống như bất kỳ bản cập nhật state nào khác.
Bây giờ chúng ta đã tìm hiểu các khái niệm cốt lõi của LangGraph.js, có lẽ sẽ hữu ích hơn nếu xem cách tất cả các phần kết hợp với nhau thông qua một ví dụ toàn diện hơn.
3. Ví dụ toàn diện về Data flow
import { START, END, StateGraph, MemorySaver } from "@langchain/langgraph";
interface State {
total: number;
}
function addG(existing: number, updated?: number) {
return existing + (updated ?? 0);
}
const builderH = new StateGraph<State>({
channels: {
total: {
value: addG,
default: () => 0,
},
},
})
.addNode("add_one", (_state) => ({ total: 1 }))
.addNode("double", (state) => ({ total: state.total }))
.addEdge(START, "add_one");
function route(state: State) {
if (state.total < 6) {
return "double";
}
return END;
}
builderH.addConditionalEdges("add_one", route);
builderH.addEdge("double", "add_one");
const memoryH = new MemorySaver();
const graphH = builderH.compile({ checkpointer: memoryH });
const threadId = "some-thread";
const config = { configurable: { thread_id: threadId } };
for await (const step of await graphH.stream(
{ total: 1 },
{ ...config,
streamMode: "values" }
)) {
console.log(step);
}
// 0 checkpoint { total: 1 }
// 1 task null
// 1 task_result null
// 1 checkpoint { total: 2 }
// 2 task null
// 2 task_result null
// 2 checkpoint { total: 4 }
// 3 task null
// 3 task_result null
// 3 checkpoint { total: 5 }
// 4 task null
// 4 task_result null
// 4 checkpoint { total: 10 }
// 5 task null
// 5 task_result null
// 5 checkpoint { total: 11 }
Để theo dõi lần chạy này, hãy kiểm tra trong LangSmith . Tôi sẽ giải thích từng bước thực hiện bên dưới:
- Đầu tiên, Graph tìm kiếm checkpoint. Không tìm thấy checkpoint nào, do đó state khởi tạo với tổng số là 0.
- Tiếp theo, Graph áp dụng đầu vào của người dùng làm bản cập nhật state. Reducer thêm đầu vào (1) vào giá trị hiện tại (0). Vào cuối siêu bước này, tổng là (1).
- Sau đó, node "add_one" được gọi và trả về 1.
- Tiếp theo, Reducer áp dụng bản cập nhật này cho tổng số hiện có (1). State hiện tại là 2.
- Sau đó, Edge có điều kiện "route" được gọi. Vì giá trị nhỏ hơn 6, chúng ta tiến hành đến Node 'double'.
- Double node lấy state hiện tại (2) và trả về state đó. Sau đó, reducer được gọi và thêm nó vào state hiện tại. State hiện tại là 4.
- Sau đó, Graph lặp lại add_one (5), kiểm tra Edge có điều kiện và tiếp tục vì nó <6. Sau khi nhân đôi, tổng là (10).
- Edge cố định lặp lại add_one (11), kiểm tra Edge có điều kiện và vì nó lớn hơn 6 nên chương trình kết thúc.
Đối với lần chạy thứ hai, chúng ta sẽ sử dụng cấu hình tương tự:
const resultH2 = await graphH.invoke({ total: -2, turn: "First Turn" }, config);
console.log(resultH2);
// { total: 10 }
Để theo dõi lần chạy này, hãy kiểm tra trong LangSmith . Tôi sẽ giải thích từng bước thực hiện bên dưới:
- Đầu tiên, Graph tìm kiếm các checkpoint. Nó tải vào bộ nhớ như state ban đầu. Tổng là (11) như trước đó.
- Tiếp theo, nó áp dụng các bản cập nhật đầu vào của người dùng. Reducer cập nhật tổng từ 11 đến -9.
- Sau đó, node "add_one" được gọi và trả về 1.
- Bản cập nhật này được áp dụng bằng cách sử dụng reducer, nâng giá trị lên 10.
- Tiếp theo, Edge điều kiện "route" được kích hoạt. Vì giá trị lớn hơn 6, chúng ta kết thúc chương trình, kết thúc tại (11).
Phần kết luận
Vậy là xong! Chúng ta đã khám phá các khái niệm cốt lõi của LangGraph.js và thấy cách sử dụng nó để tạo ra các hệ thống agents đáng tin cậy, có khả năng chịu lỗi. Bằng cách mô hình hóa các agent như các state machine, LangGraph.js cung cấp một khả năng trừu tượng mạnh mẽ để tạo ra các quy trình làm việc AI có thể mở rộng và kiểm soát được.
Hãy ghi nhớ những ý tưởng chính sau khi làm việc với LangGraph.js:
- Các node thực hiện công việc; Các Edge xác định luồng điều khiển.
- Reducer xác định chính xác cách áp dụng cập nhật state ở mỗi bước.
- Checkpoint cho phép ghi nhớ trong các lần chạy đơn lẻ và trong các tương tác nhiều lượt.
- Interrupts cho phép bạn tạm dừng, truy xuất và cập nhật state của graph cho quy trình làm việc có sự tham gia của con người.
- Các tham số có thể cấu hình cho phép kiểm soát thời gian chạy, độc lập với luồng dữ liệu thông thường.
Với các nguyên tắc này, bạn có thể khai thác toàn bộ sức mạnh của LangGraph.js để xây dựng các hệ thống tác nhân AI tiên tiến. Cảm ơn các bạn đã theo dõi bài viết
All rights reserved