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

Шматагентнае сартаванне тыкетаў падтрымкі на LangGraph і Ollama

Будуем граф сартавання тыкетаў з трох агентаў на LangGraph: класіфікатар, адказчык і фільтр чалавека, якія працуюць на лакальным Ollama ці праз API са зменай аднаго радка.

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

Крыніцы: Building a Multi-Agent Support Ticket Triage With LangGraph

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

LangGraph дапамагае стварыць такі колектыв. Агент класіфікуе кожны запіт, маршрутызатор вяршыць рашэнне, што далей, а другі агент прабывае напісаць адпаведзь. У спешных случаях автаматызацыя не прыменяецца, і запіт пераводзіцца да людзі. Граф строюецца аднойчы і працуе або локальна на Ollama, або праз хмарны API, без жадных змян у агентах чыта маршрутызацыі.

Чаму патрэбны не адні, а калькі агентаў

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

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

  • Рутынны запыт пра вырачунак можа быць адпаведзены аўтаматычна.
  • Нечыяры запыт пра функцыю атрымвае праект адпаведзі, які пазней дапрацоўвае чалавек.
  • Паведамленне на кшталт "мая прыемніка няма" ўжо апштова не павінна быць автоматызавана і должна адразу дасягнуць чалавека.
  • LangGraph керуе самэй гэтай структурамі, дзе наступны крок залежыць ад тэкушчага стану. У команды троўца членаў:

    1. Агент-класыфікатор, які чытае заявку і прызначае катэгорыю ўпорадакоўвання і прыорытэт.
    2. Агент-адказчык, які прадумвае адпаведзі на заявкі, якія можна автаматычна обрабацваць.
    3. Людзкі контроль – вузол, які перадае трыуджэныя заявкі чалавеку, а не адпаведзае сам.

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

    Як думае LangGraph

    Уявіце сабе схематычныя лініі на дошцы. LangGraph складаецца з чатырох частак:

    • Стан — гэта копія дадзеных, якая пераходзіць з станцыі на станцыю. У ёй зберагаецца вся інфармацыя, вядомая параз час або пра квітку: первыя даны, катэгорыя, прыорытэт, запрацоўваны адпаведзень, які ёсць, і статус.
    • Вузол — гэта станцыя, функцыя, якая берае стан, выконвае задачу і вяртае апошнія змены.
    • Рэчка — гэта фіксаваныя стрэлкі з адной станцыі на іншую.
    • Умовная рэчка — гэта стрэлкі, якія выбіраюцца пад час выканання. Малая функцыя рутэра чытае стан і адлучае, куды ісці, замест таго, каб завжды ісці ў одно месца.

    Так стан працяе праз вузлы, рэчкі ўз’яднаюць іх, а умовныя рэчкі дазваляюць шляху раздваівацца залежна ад таго, што зараз паказывае стан. Калі вы новачок у гэтым, стан — гэта проста слоўнік на Python з вядомым фарматам. TypedDict апісвае, якія ключы мае слоўнік.

    Тэхнічная структура

    LangGraph — гэта слой кірэграфізацыі. Ён візначае вузлы, стан і маршрутызацыю між агентамі.

    Ollama з Llama 3 запускае усё локальна, безкоштовна, на вашай сабе апаратурэ. Заявкі на падтрымку часта ўключаюць персональныя даны кліента, таму ў бізнес-сераўе існуе рэальная перадчына ў тым, каб зберагаць гэтыя даны на вашай сабе інфраструктурэ, а не надсылалі іх у сторонні API.

    Мадэль API, тут gpt-4o-mini, являецца альтэрнатываю, калі у вас няма дапаможнай апаратуры чырабаць большую продуктыўнасць, ніж дае маленькая локальная мадэль. Паколькі граф вучыцца адной раз, вы можэце пачаць працаваць локальна і пазней перайсці на API без перапісва логікі.

    Стварэнне пасля пасля

    1. Наладжэнне

    З’явіце тры пакеты: langgraph для графа, langchain-ollama як мост да Ollama і langchain-openai як мост да модэлю API.

    # Install LangGraph and the two model integrations
    pip install langgraph langchain-ollama langchain-openai
    

    Свежая віртуальная асяроддзе захоўвае пакеты гэтага праекту ад іншых вашых. У Windows яе можна стварыць за дапамою python -m venv yourname, а актывацыя водзіцца праз папку Scripts, якая знаходзіцца ўнутрь. У Mac або Linux вікорыстаюцца команды python3 -m venv venv, а пасля — source venv/bin/activate. Калі асяроддзе будзе актывацыяна, вышэўказаная команда інсталяцыі паўтараецца толькі для гэтага праекту.

    Калі Ollama яшчэ не встановлена, запрашайце ёё з офіцыйнага сайту ollama.com/download. Пасля чыго запускайце команду з тэрміналу, каб завантажыць локальную мадэль:

    # Download the Llama 3 model so we can run it locally
    ollama pull llama3
    

    2. Агенты

    Здэйнь з усё падае ў адны файл пад назвай triage.py, які дадаўся зверху да ніжынь па следуючам парадку.

    Фабрыка для модэлю. Пачніце з элемента, які дазволяе выкарыстоўваць оба бэкенды – маленькай функцыі-фабрыкі get_llm, якая вяртае правы модэль на адной заснованаў на паведамленні. Ёдыныя завадзібы фабрыкі – гэта вярнуць готовы об’ект. Уявіце сабе кавомашыну з кнопкай пад назвой „local“ і ўтарой пад назвой „API“: нажміце на адну, і вы отрымаеце вялікую чашку, не трэба ведаць, што адбываецца ўнутры.

    Функцыя прыймае параметр backend, якога стандартныя значэнне – "local"; таму якраз вызначэнне без аргумента выкарыстоўвае локальны модэль.

    # A factory that returns the model we want, so the rest of the code
    # never has to care which backend we are using.
    def get_llm(backend: str = "local"):
        if backend == "local":
            from langchain_ollama import ChatOllama
            # temperature=0 keeps classification stable and repeatable
            return ChatOllama(model="llama3", temperature=0)
        elif backend == "api":
            from langchain_openai import ChatOpenAI
            return ChatOpenAI(model="gpt-4o-mini", temperature=0)
        else:
            raise ValueError("backend must be 'local' or 'api'")
    
    
    # Choose your backend here: "local" or "api"
    BACKEND = "local"
    llm = get_llm(BACKEND)
    

    if/elif/else ланцюг пераглядае параметр і вяртае або llama3, або gpt-4o-mini, або памятку працэй. Імпорты навмесна размешчаны ў ветках. Людзі, які працуюць толькі локальна, ніколі не патрабуюць langchain-openai, і навпакі.

    temperature=0 кантролюе, насколькі адказы модэлі ўсёлякія. Вышэйшыя значэння дадаюць разнаўтворанне, тады калі 0 робіць адказы як магчыма стабільнымі і прыведамымі. Для класыфікацыі гэта абсалютна правільна, адколі той самы заявкавы документ павінен надзеянасцю атрымваць тую ж катэгорыю. BACKEND = "local" задае выбор, а наступны радок вызывае фабрыку.

    Класыфікатор. Штырь чытае заявку і вяртае катэгорыю і прыорытэт. Чыба програмна обрабатваць рэзультат, ад модэлі прасяць адказваць у фармаце JSON.

    parse_json-ы помачнік ператварае адпаведзь на справжній слоўнік у Python. Ён выкарыстоўвае find, каб спачатку знайсці пачатковыя і заканчоўныя складкі, таму што локальныя мадэлі не завжды вяртаюць чысты JSON. Яны любяюць абгортваць яго ў додатковы тэкст, такі як „Звяжліва, ось JSON: ... Спадзяюся, гэта дапаможа.“

    Функцыя classify_ticket – гэта сам агент. Яна прымеўае тэкушчы стан і вяртае апошнія змены. Яго запрос чыста візначае ролю мадэлі як асістэнта для сортавання запытаў, обмежвае адпаведзь чатырма катэгорыямі і трыма прыорытэтамі, а таксама выказвае трэбуху чыстага JSON без дадатковых прымечанняў. Запрос спецыяльна ўстановлена строгі, каб адпаведзь быў чысцейшы, і parse_json мягча працаваў. Выклік llm.invoke(prompt) адправляе яго да мадэлі.

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

    import json
    
    def parse_json(text: str) -> dict:
        # Local models sometimes wrap their JSON in extra text,
        # so we grab everything between the first { and the last }.
        start = text.find("{")
        end = text.rfind("}") + 1
        return json.loads(text[start:end])
    
    def classify_ticket(state: dict) -> dict:
        prompt = f"""You are a support triage assistant.
    Classify the following support ticket.
    Respond with ONLY a JSON object with two keys:
    "category": one of ["billing", "technical", "account", "general"]
    "priority": one of ["low", "medium", "high"]
    
    Ticket:
    {state['ticket']}"""
    
        response = llm.invoke(prompt)
        result = parse_json(response.content)
    
        # We return only the keys we want to update in the shared state
        return {"category": result["category"], "priority": result["priority"]}
    

    Автовідповідач. Другій агент складаея адказы на запіты, які можна автоматызуваты. draft_reply отрымуе стан, створае пропозіцію адказы, якая віглядае як відповідзь дружэлюзного і конкрэтного агента підтрымкі, і додае захісній елемент: без обіцянок, які не можна віконаты. Яно повертае два поля — тэкстову частыну в draft_reply і статус „auto-drafted“, шчоб пізнішэ можна було бачыты, што запіт був апрацаваній автоматычно і не піднятый до вішчых узроўняў. Пропозіція евсьною. Відповідзь ад агента підтрымкі мае буты короткою і чэсною, а обіцянкі шчодо часу поверненя грошэй або даты запуску функціі — гэта тыя, што людына мае вірышуваты сама.

    def draft_reply(state: dict) -> dict:
        prompt = f"""You are a friendly and concise support agent.
    Write a short, helpful reply to this {state['category']} ticket.
    Do not make promises you cannot keep.
    
    Ticket:
    {state['ticket']}"""
    
        response = llm.invoke(prompt)
    
        return {"draft_reply": response.content, "status": "auto-drafted"}
    

    Чалавечыя адпрацоўнікі. Трэціі агент ніколі не вызывае мадэль. Усё, што ён робіць, — гэта пазначае заявку як пераданую на далейшую обработку чалавекам. Ён вяртае порожняе draft_reply, таму што не павінна існаваць автаматычная адпаведзь, і статус „перадана на перагляд чалавекам“. Гэта чыткая пазначка пра тое, што заявкай керуе чалавек. Канэц не павінен выкарыстоўваць LLM, каб быць агентам у графе. Будзь-што, што берае статус, выкананае яго задача і вяртаемыя абнаўленні, можа знаходзіцца на той жа сцэне, незалежна ад таго, чы робіць ён вызыв модэлю, базе дадзенаў чы нічога.

    def escalate_to_human(state: dict) -> dict:
        # No auto-reply for urgent tickets. We flag them for a person.
        return {
            "draft_reply": "",
            "status": "escalated to human review",
        }
    

    3. Складанне схемы

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

    Гэтыя пяць полей таксама фіксуюць шлях квітанцыю ў сістэме. Поле ticket зберагае первісны тэкст. Класыфікатор заповнюе поля category і priority. Адпаведальны за адказ заповнюе поле draft_reply, але пасля перадачы на вышэйшую ступеню можа залішыць яго порожнім. Поле status фіксуе, дзе квітанцыя ў падсумку знаходзіцца.

    from typing import TypedDict
    
    
    class TicketState(TypedDict):
        ticket: str        # the original ticket text
        category: str      # filled in by the classifier
        priority: str      # filled in by the classifier
        draft_reply: str   # filled in by the responder (or cleared on escalation)
        status: str        # where the ticket ended up
    

    Далей ідзе раутэр, тое самае керуючы элемент. Функцыя route_by_priority чытае прыорытэт, які назначыў класыфікатор, і вяртае строку, якая паведамляе LangGraph, які шлях выбраць. Калі прыорытэт ў значэнні „высокі“, заява прабівае да людзкага аператара. У іншым случае функцыя вяртае значэнне „responder“, і заява атрымвае автаматычную адпаведзь.

    def route_by_priority(state: TicketState) -> str:
        # Urgent tickets skip the automatic reply and go to a human.
        if state["priority"] == "high":
            return "human_review"
        return "responder"
    

    Цяпер складайце граф:

    1. Створыце порожній граф за допомогою StateGraph(TicketState), перэдавшы форму стану, шчоб граф знав, які поля знаходзяцца у буферы обміну.
    2. Зарэеструйце трох агентаў за допомогою add_node. Кожэн віклік паруе коротку назву з функціею. Назва „classifier“ належыць до classify_ticket, „responder“ — до draft_reply, а „human_review“ — до escalate_to_human. У всіх наступніх кроках до вузлів будзе звертатыся за цымі назвамі, тому обірайце іх рэтэльно і зберыгайце послідовнісць.
    3. Додайце add_edge(START, „classifier“), шчоб кожэн тыкет почынався з класіфікатора. START — гэта вбудована точка входу.
  • Дадзіце умовныя грані за дапамою add_conditional_edges; яны прыймаюць вузел, дзе выконваецца розгалужэнне ("classifier"), функцію маршрутазавання (route_by_priority) і таблыцу пошуку.
  • Адправіце оба шляхі да END, таму ўсё завершаецца, незалежна ад таго, чы рэшэння праблемы было дана чы ёй была надана дапамога.
  • Выклікайце graph.compile(); яны ператвараюць вярнік у ўмоżлеваны да выканання фармат. Рэзультат зберагаецца ў app, і самэ гэта працуе з запітамі.
  • from langgraph.graph import StateGraph, START, END
    
    # Create the graph and tell it the shape of our state
    graph = StateGraph(TicketState)
    
    # Register each agent as a node
    graph.add_node("classifier", classify_ticket)
    graph.add_node("responder", draft_reply)
    graph.add_node("human_review", escalate_to_human)
    
    # Every ticket starts at the classifier
    graph.add_edge(START, "classifier")
    
    # After classifying, the router decides where to go next
    graph.add_conditional_edges(
        "classifier",
        route_by_priority,
        {
            "responder": "responder",       # normal tickets get an auto-draft
            "human_review": "human_review",  # urgent tickets get escalated
        },
    )
    
    # Both paths finish the run
    graph.add_edge("responder", END)
    graph.add_edge("human_review", END)
    
    # Compile turns our definition into something runnable
    app = graph.compile()
    

    Слоўнік, які пасылаецца да функцыі add_conditional_edges, являе сабою тэблыцу адносаў. Яе ключамі ёсць строкі, якія можа вернуць маршрутазатор, а ў значэннях — вузлы, на якія вядуць гэтыя строкі. Калі маршрутазатор вернуе "human_review", LangGraph пераходзіць да вузла human_review. Гэты аднос являеся сяродою прынцыпу, па якому граф розгалужваецца. Пазней можна дадаць новую катэгорыю обработкі, напрыклад спецыяліста з білінгу; гэта будзе адбівацца за рахунак дадагэчнага вузла, новага ключа ў тэблыцы і розгалужэння ў маршрутазаторэ.

    4. Адкліканне, локальнае або праз API

    Спачатку надаць трыбунал з прыорытэтам праз граф:

    # A clearly urgent billing issue
    urgent_ticket = (
        "I was charged twice for my subscription this month "
        "and I need a refund today. This is unacceptable."
    )
    
    result = app.invoke({"ticket": urgent_ticket})
    
    print("Category:", result["category"])
    print("Priority:", result["priority"])
    print("Status:  ", result["status"])
    print("Reply:   ", result["draft_reply"])
    

    Запускайце скрыпт за дапамою python triage.py. Можна спакавацца, што тэкст выдрукаванай адпаведзі будзе нейкшы ў кожны раз і ў розных мадэлях, але катэгорыя, прыорытэт і статус должны застацца аднаковымі, і гэта прычына наявнасці параметра temperature=0. Заявак мае высокі прыорытэт, таму рутэр адправляе яго на пераглед чалавека. Статус вяртаецца як "escalated to human review", і автаматычная адпаведзь не ствараецца.

    Спробуйце другую, звычайную заяваку:

    # A routine, low-stakes question
    routine_ticket = "How do I change the email address on my account?"
    
    result = app.invoke({"ticket": routine_ticket})
    
    print("Category:", result["category"])
    print("Priority:", result["priority"])
    print("Status:  ", result["status"])
    print("Reply:   ", result["draft_reply"])
    

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

    Пераключэнне на мадэль API

    Замест таго, каб усё гэта працавало локальна, вы можэце выконваць тое ж самае праз API; для гэтага патрэбны толькі адна кантрольная клуч і адна зменена лінія коду, завдакі функцыі-фабрыкі.

    Заўяжыцеся на платформу API OpenAI, створыце новы клуч і адразу ж копіюйце яго, ўжо таму што ён паказваецца толькі адной раз. Для безпекі не вставляйце клуч у скрыпт — зберагаюце яго як зменную сераўысу і нехай код чытае яго з таму. Выконайце команду для вашай сістэмы ў тэрмінале, заменіўшы your-key-here на ваш клуч:

    # On Windows (PowerShell)
    setx OPENAI_API_KEY "your-key-here"
    
    # On Mac/Linux
    export OPENAI_API_KEY="your-key-here"
    

    Выконваеце іх у тэрмінале, а не ў скрыпцы, і жадаўна там, дзе актываўся віртуальны серавак. Зменная задаецца на рэжыме сесіі тэрмінала, а не ўнутры venv. Пасля чаго гэты ключ знаходзіцца ў аднам месцы за межамі вашага коду, ніколі не праказываецца ў скрыпцы і не можа віпадкова патрапіць на GitHub.

    Потым зменіце адну лінію ў скрыпцы і зноў запускайце python triage.py:

    # Switch from local to API without touching the graph or the agents
    BACKEND = "api"
    llm = get_llm(BACKEND)
    

    Што ўсё гэта паказвае і куды працавац далей

    Этот прыклад відпавае рэальнаму ўседзённеваму працэсу працы. Мета не ў тым, каб заменіць команду падтрымкі, а ў тым, каб перэявіць на себе механічную сортаванню запытаў, тады калі важлівыя і трыскучыя запыты пераходзяць да спеціяліста. Паколькі граф, агенты і механізм направлення працуюць як з Ollama, так і з API, можна стварыць пратэтып на базе Ollama і перэйсці на мадэль API, калі патрэбна большая продуктыўнасць.

    Ёсць многа можлівасцей для розвітку. Агенты могу атрымваць додатковую памяць, тады наступны запыт ад таго ж кліента будзе мяць неабходны кантэкст. Можаў бы прыўязацца больш спеціялістаў, напрыклад, агента з роўнамеркам на доступ да рэальных дадзенаў аблікова. Граф могу бы з’яডнуцца з рэальной сістэмай обработкі запытаў праз ўсё той жа API, ператварыўшы скрыпт у частку рэальнага працэсу обработкі. А належны крок з удзелам людзя могу бы пракластыць запыты, якія павышаны за статусам, у чэргу на перагляд з ужэ даданай класыфікацыяй.

    Для дальшага чытання даследжэнні LangGraph і LangChain на адрэсе https://docs.langchain.com/oss/python/langgraph/overview, а таксама інструкцыі па швыракам запуску Ollama на адрэсе https://docs.ollama.com/quickstart, раскрываюць інструменты, якія выкорыстоўваюцца тут.