Галоўная / Артыкулы

LangGraph у практыцы: станы, вузлы, рэгістры і пяць шаблонаў агентаў

Выучыце, як задаць стан LangGraph за дапамою анотацый і редуктараў, з’яднаць вузлы лініямі, а таксама як рэалізаваць усе пяць основных шаблонав рабочага процесу агентаў на JavaScript.

Выдавец: REACTAPP.TOP10 хв чытання2248 слоў

Крыніцы: Agentic AI and LangGraph — Part 2: Building Nodes, Edges, and All 5 Workflow Patterns

Падзеўка: Дзе мы застаўлі

У паканальнай частцы мы расказалі пра канцэптуальную базу: агент AI спаўнае мозг (LLM) з оркестрабірам, який координуе інструменты і памяць, пры чым усё гэта належыць пад які-небудзь вид нагляду. Мы таксама пабачылі, што LangGraph ператварае гэта на граф — вузлы выконваюць задачі, рэшткі керуюць ўзаемным прыемам дадзенняў, а спяльны об’ект стану пераводзіцца праз увесь система, пры чым на кожным кроці ён з’яўляецца ў результатах дзейства редактораў.

Ёсць час перейść ад тэорыі да практычнага застосавання. Калі вы дачытайце гэты паст, вы зможазеце:

  • Заявіць стан за дапамогою Annotation, укладаючы ў тое ж самае спецыяльныя редакторы
  • З’ўязаць вузлы за дапамогою як звычных, так і умовных рэштак
  • Адаптаваць усе пяць шаблонама рабочага прыему з паканальнай пасткі — ад’юнкуванне запыткаў, маршрутызацыя, паралелізацыя, модель керавання і працоўніка, а таксама модель ацэнкі і оптымізатора — у функцыйнае JavaScript-кодаванне
  • Прыкладзіць дыяграму для кожнага шаблону, якая чытна паказвае, як усі элементы з’ѐднуюцца
  • Усі коды, прыведзены нижэй, выкарыстоўваюць @langchain/langgraph і звычнае JavaScript-кодаванне (тэтыя ж шаблоны праіснуюць у TypeScript, трэба толькі дадаць параметры типу для Annotation<T>).

    npm install @langchain/langgraph @langchain/core @langchain/openai
    

    1. Стан і анотацыі — Аб’еднаны зошык

    Перад тым, як можа запрацаваць хоць адзін вузол, LangGraph павінен разумець структуру дадзейнасі, якая праходзіць через вашу схему. Annotation — гэта спосаб описаў такой структуры.

    import { Annotation, StateGraph, START, END } from "@langchain/langgraph";
    
    const WorkflowState = Annotation.Root({
      query: Annotation(),
      searchResults: Annotation(),
      draft: Annotation(),
      finalAnswer: Annotation(),
    });
    

    Кожны ключ, які вы задаеце ўнутрь Annotation.Root({...}), стае атрыбутам у вашым об’екте спільнага стану. Кожны вузол можа чытаць значэння гэтых атрыбутаў, а калі вяртаецца, яму трэба ўключыць толькі тыя атрыбуты, якія ён фактычна моць выкарыстаць — LangGraph сам з’едначае гэты частковы результат з полным станам.

    Reducer’ы: Выбір спосабу з’едначэння апдэйтав

    Якщо вы не задаеце іншае, значэнне, якое вяртаецца вузлом, заменяе адпаведны атрыбут цэлком. Гэта падходзіць добра для чаго-небудзь уродзе draft, дзе вам патрабуецца толькі найновейшая версія. Але гэта не працюе для чаго-небудзь уродзе спіса паведамленняў, дзе перазапісвы могу стерць усё, што было раней. Самэлькі reducer дапамагае — це функцыя, якая визначае, як з’еднаць існуюча значэнне з новым апдэйтом.

    const AgentState = Annotation.Root({
      // Default behavior: last write wins
      draft: Annotation(),
    
      // Custom reducer: append instead of overwrite
      messages: Annotation({
        reducer: (current, update) => [...current, ...update],
        default: () => [],
      }),
    });
    

    Калі такое налажанае, якшо nodeA вяртае { messages: ["hi"] }, а nodeB — { messages: ["hello"] }, редюсер з’яеднвае іх у ["hi", "hello"], а не дазвае аднаму з’явіцца над іншым. Гэты механізм ўскладнілы для безпечнага запуску паралельных галузей (дывіцеся шаблон 3 нижэй) — два вузлы можаюць пісаць у той самы ключ стану адночасна без суперскульніцтва.

    2. Вузлы — елементы роботы

    Вузел — гэта проста функцыя, сінхронная або асінхронная, якая прыме текущы стан і вяртае частковае апдэйтаванне яго.

    async function draftNode(state) {
      const response = await llm.invoke(`Write a short draft about: ${state.query}`);
      return { draft: response.content };
    }
    

    Вы рэгіструеце яго ў графе за дапамою addNode:

    const graph = new StateGraph(WorkflowState)
      .addNode("draft", draftNode);
    

    Заўважыце, што сам вузел не мае жаданага усведамлення пра структуру графа навакола яго. Ён проста спрыяе змěне стану і стварае апдэйт. З’ѐеднанне вузелаў адбываецца выключна за дапамогою ребер.

    3. Ребра — падключэнне потоку

    Ребра вялікай меры вплываюць на порядак выканання. LangGraph падтрымляе тры відэны:

    Нормальныя ребра — фіксаваны, безусловны шлях ад однаго вузела да іншага.

    graph.addEdge(START, "draft");   // entry point: where the graph starts
    graph.addEdge("draft", "polish");
    graph.addEdge("polish", END);    // exit point: where the graph stops
    

    Умовныя ребра — функцыя маршрутызаціі пераглядае текущы стан і вяршыцца, який вузел будзе выкананы далей.

    graph.addConditionalEdges("classify", (state) => {
      if (state.category === "billing") return "handleBilling";
      if (state.category === "technical") return "handleTechnical";
      return "handleGeneral";
    });
    

    Паралельныя (фан-аут) ребра — калі адзін вузел мае калькольвыя нормальныя ребра, усі ўсіх іх цілі выкананыя адночасна.

    graph.addEdge("orchestrator", "workerA");
    graph.addEdge("orchestrator", "workerB");
    graph.addEdge("orchestrator", "workerC");
    

    Кожны граф должен мяць прынеймна адную грань, яка пачынаецца з START, і маршрут, які ў канцы выходзіць на END; інакша LangGraph не будзе ведаць, дзе трэба пачаць і заканчыць выконанне.

    Так как стан, вузлы і гранны ўжо розглянуты, у вас є всі неабходны элементы для стварэння пяці шаблонаў рабочага прыему, якія былі представлены раней.

    4. Шаблон 1 — Аўтоматызаваная абработка запитаў

    Канцэпцыя: разбіць большую задачу на лінейную ланцюговую серію вызоў LLM, пры чым кожны крок будуе на тым, што было раней.

    START → outline → draft → polish → END
    
    const ChainState = Annotation.Root({
      topic: Annotation(),
      outline: Annotation(),
      draft: Annotation(),
      final: Annotation(),
    });
    
    async function outlineNode(state) {
      const res = await llm.invoke(`Create a short outline for: ${state.topic}`);
      return { outline: res.content };
    }async function draftNode(state) {
      const res = await llm.invoke(`Write a draft based on this outline:\n${state.outline}`);
      return { draft: res.content };
    }async function polishNode(state) {
      const res = await llm.invoke(`Polish and tighten this draft:\n${state.draft}`);
      return { final: res.content };
    }const chainGraph = new StateGraph(ChainState)
      .addNode("outline", outlineNode)
      .addNode("draft", draftNode)
      .addNode("polish", polishNode)
      .addEdge(START, "outline")
      .addEdge("outline", "draft")
      .addEdge("draft", "polish")
      .addEdge("polish", END)
      .compile();const result = await chainGraph.invoke({ topic: "the history of tea" });
    console.log(result.final);
    

    Кожны вузел тут чытае толькі поле, яке запісаў вузел да ўжо — няма розгалужэнняў чы цыклаў, толькі простая лінія ад пачатку да канца.

    5. Шаблон 2 — Маршрутаванне

    Канцэпцыя: спачатку класіфікаваць прыйняты вхідны данны, а пасля перадаць іх аднойчы з кількох спецыяльна прызначаных шляхоў обробкі.

    ┌──> handleBilling   ──┐
    START → classify ───┼──> handleTechnical ─┼──> END
                        └──> handleGeneral   ──┘
    
    const RouteState = Annotation.Root({
      message: Annotation(),
      category: Annotation(),
      response: Annotation(),
    });
    
    async function classifyNode(state) {
      const res = await llm.invoke(
        `Classify this message as "billing", "technical", or "general". Only reply with one word.\n${state.message}`
      );
      return { category: res.content.trim().toLowerCase() };
    }async function handleBilling(state) {
      return { response: `[Billing team] ${await llm.invoke(state.message).then(r => r.content)}` };
    }
    async function handleTechnical(state) {
      return { response: `[Tech support] ${await llm.invoke(state.message).then(r => r.content)}` };
    }
    async function handleGeneral(state) {
      return { response: `[General desk] ${await llm.invoke(state.message).then(r => r.content)}` };
    }const routeGraph = new StateGraph(RouteState)
      .addNode("classify", classifyNode)
      .addNode("handleBilling", handleBilling)
      .addNode("handleTechnical", handleTechnical)
      .addNode("handleGeneral", handleGeneral)
      .addEdge(START, "classify")
      .addConditionalEdges("classify", (state) => {
        if (state.category === "billing") return "handleBilling";
        if (state.category === "technical") return "handleTechnical";
        return "handleGeneral";
      })
      .addEdge("handleBilling", END)
      .addEdge("handleTechnical", END)
      .addEdge("handleGeneral", END)
      .compile();
    

    Функцыя, яку вы пасылайте да addConditionalEdges, должна вяртаць назву наступнага вузла, які трэба адвіцарыць — гэта механізм, які дазволяе графу дынамічна выбіраць шлях у замест на тое, каб завжды следаваць однаму і тому ж маршруту.

    6. Шаблон 3 — Паралелізацыя

    Канцэпцыя: адразу запускаць калькі LLM проты разных частак задання, а пасля сумаваць рэзультаты.

    ┌──> summarizeDocA ──┐
    START → split ──┼──> summarizeDocB ──┼──> merge → END
                    └──> summarizeDocC ──┘
    
    const ParallelState = Annotation.Root({
      documents: Annotation(),
      summaries: Annotation({
        reducer: (current, update) => [...current, ...update],
        default: () => [],
      }),
      finalSummary: Annotation(),
    });
    
    function splitNode(state) {
      return {}; // no-op entry point, just fans out to the summarizers below
    }async function summarizeDocA(state) {
      const res = await llm.invoke(`Summarize:\n${state.documents[0]}`);
      return { summaries: [res.content] };
    }
    async function summarizeDocB(state) {
      const res = await llm.invoke(`Summarize:\n${state.documents[1]}`);
      return { summaries: [res.content] };
    }
    async function summarizeDocC(state) {
      const res = await llm.invoke(`Summarize:\n${state.documents[2]}`);
      return { summaries: [res.content] };
    }async function mergeNode(state) {
      const res = await llm.invoke(`Combine these summaries into one:\n${state.summaries.join("\n---\n")}`);
      return { finalSummary: res.content };
    }const parallelGraph = new StateGraph(ParallelState)
      .addNode("split", splitNode)
      .addNode("summarizeDocA", summarizeDocA)
      .addNode("summarizeDocB", summarizeDocB)
      .addNode("summarizeDocC", summarizeDocC)
      .addNode("merge", mergeNode)
      .addEdge(START, "split")
      .addEdge("split", "summarizeDocA")
      .addEdge("split", "summarizeDocB")
      .addEdge("split", "summarizeDocC")
      .addEdge("summarizeDocA", "merge")
      .addEdge("summarizeDocB", "merge")
      .addEdge("summarizeDocC", "merge")
      .addEdge("merge", END)
      .compile();
    

    Зверніце увагу на редуктар, прыўязаны да summaries: без яго пасляпэўны паралельны відгалужэння проста перазапісае пярэднія рэзультаты, у замест на тое, каб усе трое выходных даных безпечна накапліваліся ў масе.

    7. Шаблон 4 — Оркестрайтар-Рабочы

    Канцэпцыя: адзін вызов LLM дзельняе загальную задачу на меншыя падзядныя заведаменні і перадае кожна з іх рабочаму вузлу, пасля чым фінальны крок складае ўсё в адны рэзультат. Гэта дужа супаняеся з паралелізацыёй, але тут оркестрайтар у момент выконання вялічыну колькасці рабочых узлаў і тое, што павінен рабіць кожны з іх, адначынае сам, у працоўніку не ёсць фіксаванага плану заздалегідь.

    START → orchestrator ──> worker(s), one per subtask ──> compile → END
    
    const OrchestratorState = Annotation.Root({
      task: Annotation(),
      subtasks: Annotation(),
      sections: Annotation({
        reducer: (current, update) => [...current, ...update],
        default: () => [],
      }),
      report: Annotation(),
    });
    
    async function orchestratorNode(state) {
      const res = await llm.invoke(
        `Break this task into 3-5 short subtasks, one per line:\n${state.task}`
      );
      const subtasks = res.content.split("\n").filter(Boolean);
      return { subtasks };
    }async function workerNode(state) {
      // In a real app you'd invoke one worker per subtask (e.g. via Send / map-reduce);
      // here's the simple per-subtask logic each worker runs:
      const results = await Promise.all(
        state.subtasks.map((subtask) => llm.invoke(`Write a section about: ${subtask}`))
      );
      return { sections: results.map((r) => r.content) };
    }async function compileNode(state) {
      const res = await llm.invoke(`Compile these sections into one report:\n${state.sections.join("\n\n")}`);
      return { report: res.content };
    }const orchestratorGraph = new StateGraph(OrchestratorState)
      .addNode("orchestrator", orchestratorNode)
      .addNode("worker", workerNode)
      .addNode("compile", compileNode)
      .addEdge(START, "orchestrator")
      .addEdge("orchestrator", "worker")
      .addEdge("worker", "compile")
      .addEdge("compile", END)
      .compile();
    

    Якщо вам патрэбна сапраўдная дынамічная структура — тое значыць, што колькасць рабочых вузлаў фактычна зменяецца з аднаго запуску да іншага — LangGraph.js прысвячае цэй задачы API Send, які дазволяе ствараць по аднаму вызову рабочага вузла на кожна падзядная заведаменні ў момент выканання. Гэты механізм заслуговае на самастоятельны дэтальны апіс, але прадставленая тут наладка вже падкрывае большую частку ўседзённых патрабаванняў, такіх як стварэнне звярнункоў чы будова агентаў для даследжэння.

    8. Шаблон 5 — Адміністратар-оптымаўчык

    Канцэпцыя: адна вузел выдае результат, другі вузел яго перакантролюе, а умовны рэшт выконанне направляе знадворы да генератора, пакуль результат не будзе прыняты. Цэй ёсць шаблон, у яком фактычна выкарыстоўваецца падтрымка цыклаў у LangGraph.

    START → generate → evaluate ──(fail)──> generate  (loop)
                           │
                         (pass)
                           ↓
                          END
    
    const LoopState = Annotation.Root({
      prompt: Annotation(),
      code: Annotation(),
      feedback: Annotation(),
      passed: Annotation(),
      attempts: Annotation({
        reducer: (current, update) => current + update,
        default: () => 0,
      }),
    });
    
    async function generateNode(state) {
      const context = state.feedback ? `\nPrevious feedback: ${state.feedback}` : "";
      const res = await llm.invoke(`Write code for: ${state.prompt}${context}`);
      return { code: res.content, attempts: 1 };
    }async function evaluateNode(state) {
      const res = await llm.invoke(
        `Review this code for bugs. Reply "PASS" if it's correct, otherwise explain what's wrong:\n${state.code}`
      );
      const passed = res.content.trim().startsWith("PASS");
      return { passed, feedback: passed ? null : res.content };
    }const loopGraph = new StateGraph(LoopState)
      .addNode("generate", generateNode)
      .addNode("evaluate", evaluateNode)
      .addEdge(START, "generate")
      .addEdge("generate", "evaluate")
      .addConditionalEdges("evaluate", (state) => {
        if (state.passed || state.attempts >= 4) return END; // safety cap on retries
        return "generate";
      })
      .compile();
    

    Заўважыце, што поле attempts выкарыстоўвае редуктар, які дадае да чысла, якае ведацца, а не заменяе яго — гэта таксама служыць захоўнікам. Без якога-небудзь ліміту на праказы, адміністратар, які ніколі не затверджвае результат, могаць прыводзіць цыкл у дзейнасць несканчэнна.

    9. Кампіляванне і запуск будзь-каго з гэтых

    Незалежна ад таго, які шаблон вы створылі, вы завершаеце адносна тады ж:

    const app = graph.compile();
    const finalState = await app.invoke({ /* initial state fields */ });
    

    Выклік .compile() пераканальваецца, чы ўсі вузлы дасягнучы, чы START і END належна з’ўязаны, і ператварае опис вашай графікі на выконвальны об’ект. Пасля чаго .invoke() запускае графіку з пачатку да канца і вяртае рэзультуючы стан — гэты ўсё той жа цыкл выконання, які быў концэптуальна описаны раней у гэтай серыі.

    Заключэнне

    У ходзе гэтага карусельнага адгуквання ідеі з тэорыі ператварыліся ў рабочы код: Annotation.Root для вялічэння стану, звычныя функцыі як вузлы, addEdge і addConditionalEdges для кантролю прайму, а таксама редукцыяры для таго, каб сумаварныя або паўтаральныя апдэйты не перакрылі адзін другога. За дапамогою толькі гэтых чатырох элементаў кожны з пяці расглянутых шаблонаў рабочага прайму — ланцюгаванне, маршрутызацыя, паралелізацыя, оркестрацыя-рабочы і адмаціраванне-оптымізацыя — выказваецца як разныя комбінацыі тых сабе основных інструментаў.

    Наступны частак у гэтай серыі практыкуе внешнія інструменты, дадае памяць, каб стан мог утримвацца між сэсіямі, і адкрывае этап, калі чалавек пераглядае і затварджае дзеянні пры ўсуненні іх да адклонення — гэтыя элементы неабходны, каб ператварыць гэтыя графы на справжні агента.