LangGraph: рэжымы запуску і human-in-the-loop — канспект
Прыклады ў гэтым канспекце пабудаваны вакол файлаў гэтага ўрока:
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 робіць адзін выклік мадэлі і вяртае:
ts1{ meta: { requiredData }, cv, questionsToUser }
Калі questionsToUser не пусты — значыць, мадэлі не хапае інфармацыі, і трэба задаць пытанні кандыдату, атрымаць адказ, і паспрабаваць extract яшчэ раз. Пакуль гэтага цыклу няма: код выклікаецца адзін раз і спыняецца, нават калі ёсць пытанні.
Апісаць гэты цыкл голым while можна, але як толькі дадаецца "спыніцца і счакаць адказ чалавека, магчыма праз хвіліну, магчыма праз дзень" — гэта ўжо не проста цыкл у адной функцыі, а стан, які трэба недзе захаваць паміж выклікамі. Менавіта тут LangGraph дае гатовы механізм (checkpointer), а не толькі прыгожы сінтаксіс для галінавання.
2. Мінімальны граф для гэтага сцэнара
Дапоўнены варыянт таго ж каркаса, што пачаты ў orchestrator.ts:
ts1import { 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});
Схема цыкла:
text1__start__ → extract ──questionsToUser пусты──► __end__ 2 ▲ │ 3 │ ёсць пытанні 4 │ ▼ 5 └────────── askUser (тут граф спыняецца і чакае чалавека)
3. Рэжымы запуску скампіляванага графа
app — гэта звычайны Runnable (той жа інтэрфейс, што і ў мадэляў у LangChain), таму .invoke() не адзіны спосаб яго запусціць.
invoke — адзін запуск, толькі фінальны/спынены стан
ts1const 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 — прамежкавыя абнаўленні па меры выканання нод
ts1for await (const chunk of await app.stream({ candidateText, vacancyText }, config)) { 2 console.log(chunk); // напр. { extract: { cv: {...}, questionsToUser: [...] } } 3}
Карысна, каб паказваць прагрэс "extract → askUser → extract → ..." у інтэрфейсе, а не чакаць усё разам.
batch — некалькі незалежных запускаў паралельна
ts1const results = await app.batch([ 2 { candidateText: resumeA, vacancyText: vacancyA }, 3 { candidateText: resumeB, vacancyText: vacancyB }, 4]);
Кожны элемент масіва — асобны, незалежны прагон графа (розныя thread_id, калі патрэбны свае checkpoint'ы).
streamEvents — самая дэталёвая падзейная плынь
ts1for 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)дазваляе "уліць" адказ чалавека ў стан перад тым, як працягнуць.
Поўны цыкл "спытаць кандыдата, пакуль не хопіць інфармацыі":
ts1const 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 адтуль).
-
Дапоўніце
orchestrator.ts. У пустымResearchState(уorchestrator.ts) дадайце паліcandidateText,vacancyText,cv,questionsToUser(як у прыкладзе вышэй). Дадайце нодyextract, якая выклікаеcreateCVFromText, іaddEdge("__start__", "extract"),addEdge("extract", "__end__"). Праверце, штоapp.invoke({ candidateText, vacancyText })вяртае стан з запоўненымcv. -
Дадайце цыкл пытанняў. Да графа з практыкавання 1 дадайце ноду
askUserі ўмоўны пераходaddConditionalEdges("extract", ...), як у раздзеле 2. Скампілюйце зinterruptBefore: ["askUser"]. -
streamзаместinvoke. Запусціце граф з практыкавання 2 празapp.stream(...)і выведзіце ў кансоль назву кожнай ноды, якая адпрацавала, па меры выканання. -
Поўны human-in-the-loop цыкл. Напішыце функцыю
runInteractiveCV(candidateText, vacancyText), якая: запускае граф, пакуль ёсцьquestionsToUser— друкуе іх у кансоль і чытае адказ празreadline/prompt, уліваe адказ празupdateState, працягвае празinvoke(null, config). У канцы вяртае гатовыcv. -
Два незалежныя кандыдаты. Выкарыстоўваючы
app.batch([...]), запусціце граф адразу для дзвюх пар (рэзюмэ, вакансія) з розныміthread_idуconfigдля кожнага элемента. Пераканайцеся, што іх стан уcheckpointerне змешваецца (пытанні аднаго кандыдата не трапляюць у стан другога).
Каментары
(Каб даслаць каментар залагуйцеся ў свой уліковы запіс)