Галоўная > LangGraph: рэжымы запуску і human-in-the-loop — канспект

LangGraph: рэжымы запуску і human-in-the-loop — канспект

AI
langgraph

Прыклады ў гэтым канспекце пабудаваны вакол файлаў гэтага ўрока:

  • cv_creator_custom/dataPrepare.ts — экстракцыя CV з тэксту рэзюмэ + вакансіі (пакуль лінейны код, без графа)
  • cv_creator_custom/orchestrator.ts, cv_creator_custom/chat.ts — пачатак графа (StateGraph + MemorySaver), пакуль пусты каркас

Базавыя паняцці LangGraph (nodes, edges, Annotation) тут не паўтараюцца — яны ўжо разабраны ў lesson9_langchain/LANGGRAPH_FOR_BEGINNERS.md. Тут — пра тое, што пасля: як запускаць гатовы граф і як зрабіць паўзу пасярод графа, каб спытаць чалавека.


1. Чаму dataPrepare.ts пакуль не патрабуе графа

createCVFromText у dataPrepare.ts робіць адзін выклік мадэлі і вяртае:

ts
1{ meta: { requiredData }, cv, questionsToUser }

Калі questionsToUser не пусты — значыць, мадэлі не хапае інфармацыі, і трэба задаць пытанні кандыдату, атрымаць адказ, і паспрабаваць extract яшчэ раз. Пакуль гэтага цыклу няма: код выклікаецца адзін раз і спыняецца, нават калі ёсць пытанні.

Апісаць гэты цыкл голым while можна, але як толькі дадаецца "спыніцца і счакаць адказ чалавека, магчыма праз хвіліну, магчыма праз дзень" — гэта ўжо не проста цыкл у адной функцыі, а стан, які трэба недзе захаваць паміж выклікамі. Менавіта тут LangGraph дае гатовы механізм (checkpointer), а не толькі прыгожы сінтаксіс для галінавання.


2. Мінімальны граф для гэтага сцэнара

Дапоўнены варыянт таго ж каркаса, што пачаты ў orchestrator.ts:

ts
1import { Annotation, StateGraph, MemorySaver } from "@langchain/langgraph";
2import { createCVFromText } from "./dataPrepare.ts"; // умоўна экспартавана
3
4const CVState = Annotation.Root({
5  candidateText: Annotation<string>({ reducer: (_, u) => u, default: () => "" }),
6  vacancyText: Annotation<string>({ reducer: (_, u) => u, default: () => "" }),
7  cv: Annotation<CV_Structure | null>({ reducer: (_, u) => u, default: () => null }),
8  questionsToUser: Annotation<string[]>({ reducer: (_, u) => u, default: () => [] }),
9  userAnswer: Annotation<string>({ reducer: (_, u) => u, default: () => "" }),
10});
11
12async function extractNode(state: typeof CVState.State) {
13  const result = await createCVFromText("", state.candidateText, state.vacancyText);
14  return { cv: result.data.cv, questionsToUser: result.data.questionsToUser };
15}
16
17function afterExtract(state: typeof CVState.State): "askUser" | "__end__" {
18  return state.questionsToUser.length > 0 ? "askUser" : "__end__";
19}
20
21// Сама нода нічога не робіць — граф спыняецца ПЕРАД ёй (гл. interruptBefore ніжэй).
22async function askUserNode() {
23  return {};
24}
25
26const workflow = new StateGraph(CVState)
27  .addNode("extract", extractNode)
28  .addNode("askUser", askUserNode)
29  .addEdge("__start__", "extract")
30  .addConditionalEdges("extract", afterExtract, {
31    askUser: "askUser",
32    __end__: "__end__",
33  })
34  .addEdge("askUser", "extract");
35
36const checkpointer = new MemorySaver();
37const app = workflow.compile({
38  checkpointer,
39  interruptBefore: ["askUser"],
40});

Схема цыкла:

text
1__start__ → extract ──questionsToUser пусты──► __end__
2               ▲              │
3               │        ёсць пытанні
4               │              ▼
5               └────────── askUser  (тут граф спыняецца і чакае чалавека)

3. Рэжымы запуску скампіляванага графа

app — гэта звычайны Runnable (той жа інтэрфейс, што і ў мадэляў у LangChain), таму .invoke() не адзіны спосаб яго запусціць.

invoke — адзін запуск, толькі фінальны/спынены стан

ts
1const config = { configurable: { thread_id: "cv-session-1" } };
2
3const state = await app.invoke(
4  { candidateText, vacancyText },
5  config,
6);

Тут state — гэта стан пасля extract. Калі questionsToUser не пусты, граф спыніцца роўна перад askUser (з-за interruptBefore) і верне прамежкавы стан, а не памылку і не "залежыць".

stream — прамежкавыя абнаўленні па меры выканання нод

ts
1for await (const chunk of await app.stream({ candidateText, vacancyText }, config)) {
2  console.log(chunk); // напр. { extract: { cv: {...}, questionsToUser: [...] } }
3}

Карысна, каб паказваць прагрэс "extract → askUser → extract → ..." у інтэрфейсе, а не чакаць усё разам.

batch — некалькі незалежных запускаў паралельна

ts
1const results = await app.batch([
2  { candidateText: resumeA, vacancyText: vacancyA },
3  { candidateText: resumeB, vacancyText: vacancyB },
4]);

Кожны элемент масіва — асобны, незалежны прагон графа (розныя thread_id, калі патрэбны свае checkpoint'ы).

streamEvents — самая дэталёвая падзейная плынь

ts
1for await (const event of app.streamEvents(
2  { candidateText, vacancyText },
3  { ...config, version: "v2" },
4)) {
5  if (event.event === "on_chat_model_stream") {
6    process.stdout.write(event.data.chunk.content ?? "");
7  }
8}

Дае падзеі не толькі "нода пачалася/скончылася", а і токены LLM унутры нод па меры генерацыі — для стрымінгу тэксту ў UI ў рэальным часе.


4. Human-in-the-loop: interrupt + аднаўленне праз checkpointer

Гэта тое, чаго не хапае голаму invoke(): паўза пасярод графа і працяг пазней, магчыма зусім у іншым HTTP-запыце.

Ключавыя рэчы:

  • checkpointer (тут MemorySaver, у продзе — напрыклад SqliteSaver/PostgresSaver) захоўвае стан графа паміж выклікамі, прывязаны да thread_id.
  • interruptBefore: ["askUser"] пры compile() кажа: "спыніся, не заходзячы ў гэтую ноду".
  • app.invoke(null, config) (менавіта null замест уваходу!) азначае "не пачынай спачатку, а працягні з апошняга checkpoint'а гэтага thread_id".
  • app.updateState(config, patch) дазваляе "уліць" адказ чалавека ў стан перад тым, як працягнуць.

Поўны цыкл "спытаць кандыдата, пакуль не хопіць інфармацыі":

ts
1const config = { configurable: { thread_id: "cv-session-1" } };
2
3let state = await app.invoke({ candidateText, vacancyText }, config);
4
5while (state.questionsToUser.length > 0) {
6  console.log("Пытанні кандыдату:", state.questionsToUser);
7
8  const answer = await askHumanSomehow(); // stdin, чат-паведамленне, форма — што заўгодна
9
10  await app.updateState(config, {
11    userAnswer: answer,
12    candidateText: `${state.candidateText}\n${answer}`,
13  });
14
15  state = await app.invoke(null, config); // працяг з чэкпоінта, не з пачатку
16}
17
18console.log("Гатовы CV:", state.cv);

Важна: паміж двума выклікамі app.invoke(null, config) можа прайсці і секунда, і дзень — стан жыве ў checkpointer, а не ў пераменных праграмы. Гэта і адрознівае граф ад проста while-цыкла ў адной функцыі: цыкл памірае разам з працэсам, а граф з checkpointer'ам — не.


5. Кароткая табліца

СпосабКалі выкарыстоўваць
invokeпросты аднаразовы запуск; чакаем фінал ці кропку interrupt
streamтрэба паказваць прагрэс па нодах у рэальным часе
batchнекалькі незалежных уваходаў паралельна
streamEventsпатрэбныя токены LLM ці ўнутраныя падзеі нод
checkpointer + interruptBefore + invoke(null, config)трэба паўза пасярод графа і чаканне ўводу чалавека (наш questionsToUser)

6. Практыкаванні

Рабіце ў асобным .ts-файле побач з dataPrepare.ts (можна выкарыстоўваць рэальны createCVFromText адтуль).

  1. Дапоўніце orchestrator.ts. У пустым ResearchStateorchestrator.ts) дадайце палі candidateText, vacancyText, cv, questionsToUser (як у прыкладзе вышэй). Дадайце нодy extract, якая выклікае createCVFromText, і addEdge("__start__", "extract"), addEdge("extract", "__end__"). Праверце, што app.invoke({ candidateText, vacancyText }) вяртае стан з запоўненым cv.

  2. Дадайце цыкл пытанняў. Да графа з практыкавання 1 дадайце ноду askUser і ўмоўны пераход addConditionalEdges("extract", ...), як у раздзеле 2. Скампілюйце з interruptBefore: ["askUser"].

  3. stream замест invoke. Запусціце граф з практыкавання 2 праз app.stream(...) і выведзіце ў кансоль назву кожнай ноды, якая адпрацавала, па меры выканання.

  4. Поўны human-in-the-loop цыкл. Напішыце функцыю runInteractiveCV(candidateText, vacancyText), якая: запускае граф, пакуль ёсць questionsToUser — друкуе іх у кансоль і чытае адказ праз readline/prompt, уліваe адказ праз updateState, працягвае праз invoke(null, config). У канцы вяртае гатовы cv.

  5. Два незалежныя кандыдаты. Выкарыстоўваючы app.batch([...]), запусціце граф адразу для дзвюх пар (рэзюмэ, вакансія) з рознымі thread_id у config для кожнага элемента. Пераканайцеся, што іх стан у checkpointer не змешваецца (пытанні аднаго кандыдата не трапляюць у стан другога).

Admin, 2026-08-23
Каментары

    (Каб даслаць каментар залагуйцеся ў свой уліковы запіс)