Open WebUI는 브라우저에서 LLM을 쓰기 위한 채팅 UI다. OpenAI 호환 API를 붙이면 로컬 모델이든, 외부 모델이든, 자체 서버든 비슷한 방식으로 사용할 수 있다. 사용자 입장에서는 ChatGPT처럼 메시지를 입력하고 응답을 받는다. 관리자 입장에서는 모델 엔드포인트를 하나 더 등록하면 된다.
GitHubGitHub - open-webui/open-webui: User-friendly AI Interface (Supports Ollama, OpenAI API, ...)User-friendly AI Interface (Supports Ollama, OpenAI API, ...) - open-webui/open-webuihttps://github.com/open-webui/open-webuiHermes Agent도 이 흐름에 맞춰 붙는다. Open WebUI가 메시지를 보내고, Hermes가 응답을 돌려주고, 화면은 그 응답을 스트리밍하면 된다. 다만 Hermes는 일반적인 LLM 서버보다 더 많은 일을 한다.
GitHubGitHub - NousResearch/hermes-agent: The agent that grows with youThe agent that grows with you. Contribute to NousResearch/hermes-agent development by creating an account on GitHub.https://github.com/NousResearch/hermes-agentHermes는 LLM에 파일, 터미널, 브라우저, 외부 API 같은 도구를 연결해 실제 작업을 수행하는 에이전트 런타임이다. 사용자의 요청을 받아 모델을 호출하고, 필요하면 도구를 실행하고, 중간 상태를 만들고, 때로는 사용자 승인을 기다린다. 어떤 작업은 브라우저의 HTTP 연결이 끊긴 뒤에도 서버에서 계속 진행되어야 한다.
Open WebUI에 Hermes를 붙일 때 문제는 여기서 시작한다. Open WebUI는 채팅 메시지와 assistant 응답을 중심으로 동작하고, Hermes는 그 뒤에서 상태를 가진 작업을 실행한다.
Open WebUI는 채팅 메시지를 다룬다
이 구조는 OpenAI Chat Completions API와 잘 맞는다. 사용자는 메시지를 보내고, 서버는 assistant 응답을 돌려준다. 응답은 토큰 단위로 스트리밍될 수 있고, 대화 기록에는 user message와 assistant message가 남는다.
이미 많은 도구가 이 모양을 이해한다. Open WebUI도 그렇고, LiteLLM이나 vLLM, Ollama 같은 도구도 같은 인터페이스를 따른다. 서버가 OpenAI 호환 응답만 잘 돌려주면 UI는 크게 신경 쓸 것이 없다.
짧은 LLM 응답을 스트리밍하는 용도에서는 Chat Completions 방식으로 충분하다.
하지만 이 기본값은 “채팅 응답”을 중심에 둔다. 사용자가 메시지를 보내면 assistant가 대답한다. 중간에 무슨 일이 있었는지는 대체로 중요하지 않다. 채팅창에는 최종 assistant message가 남는다.
Hermes는 응답을 만들고 도구를 실행한다
Hermes Agent는 이 흐름과 조금 다르다.
Hermes도 사용자 메시지를 받고 assistant 응답을 만들 수 있다. 하지만 그 사이에서 하는 일이 단순하지 않다. 하나의 답변이 만들어지기까지 이런 단계가 이어질 수 있다.
예를 들어 “이 프로젝트에서 테스트 깨지는 이유를 찾아줘”라는 요청을 생각해보자. 에이전트는 바로 답변을 쓰기보다 먼저 파일을 살펴보고, 테스트 명령을 실행하고, 실패 로그를 읽고, 관련 코드를 다시 찾아본다. 필요하면 파일을 수정하고 테스트를 다시 돌린다.
이때 사용자는 마지막 문장과 함께 실행 중간 상태도 봐야 한다.
사용자에게는 다음 정보가 함께 필요하다. 작업이 진행 중인지, 어떤 도구가 실행되고 있는지, 도구 실행이 실패했는지, 승인이 필요한지, 새로고침 후에도 같은 작업에 다시 붙을 수 있는지.
그래서 Open WebUI에 보여줘야 할 정보도 assistant 응답 하나로 끝나지 않는다.
마지막에 텍스트 답변이 나온다는 점은 같지만, 중간 상태를 어떻게 다룰지가 달라진다. Open WebUI에 Hermes를 붙이면 이 차이가 API 모양의 문제로 나타난다.
/v1/chat/completions에서 애매해지는 것들
기본 구조는 단순했다. Open WebUI의 메시지를 받아 Hermes에 넘기고, Hermes가 돌려주는 SSE delta를 다시 Open WebUI에 흘려보낸다. 이 방식은 자연스럽다. 기존 채팅 모델과 같은 형태이고, Open WebUI도 이미 이 구조를 잘 이해한다.
문제는 작업이 길어지고, 도구 실행이 섞이고, 브라우저 연결이 끊어질 때 드러난다.
첫 번째 문제는 연결이 끊겼을 때 작업의 상태가 애매해진다는 점이다.
Chat Completions의 스트리밍은 보통 지금 열려 있는 HTTP/SSE 연결에 묶여 있다. 브라우저를 새로고침하거나 네트워크가 끊기면 클라이언트는 곤란해진다.
방금 작업은 끝난 걸까? 아직 서버에서 돌고 있을까? 다시 붙을 수 있을까?
Chat Completions 방식에서는 실행을 식별하는 별도 ID가 없다. 클라이언트가 다시 붙으려면 이전 요청과 현재 서버 작업을 연결할 방법이 필요하다.
두 번째 문제는 도구 실행 상태를 텍스트로 흉내 내게 된다는 점이다.
Chat Completions의 delta는 assistant message의 텍스트 조각을 흘려보내는 데 잘 맞는다. 하지만 에이전트 실행에는 텍스트 조각이 아닌 상태 변화가 섞인다. 도구가 시작되었다는 신호, 도구가 끝났다는 신호, 승인이 필요하다는 신호, 실행이 실패했다는 신호는 assistant가 사용자에게 말하는 문장과 성격이 다르다.
이런 상태를 모두 assistant message 문자열 안에 넣으면 답변과 상태가 섞인다. 사용자에게 보여줄 말과 시스템이 관리해야 할 상태가 같은 스트림 안에 들어간다. “터미널 실행 중...” 같은 문장을 assistant가 말한 것처럼 넣을 수는 있지만, 그러면 UI는 그 문장이 상태인지 답변인지 구분하기 어렵다.
세 번째 문제는 작업의 수명이 HTTP 요청보다 길 수 있다는 점이다.
에이전트는 테스트를 실행하거나, 웹을 검색하거나, 외부 API를 기다릴 수 있다. 어떤 작업은 몇 초 안에 끝나지 않는다. 이때 HTTP 요청 하나의 수명과 에이전트 작업의 수명을 같게 보는 것은 부담스럽다.
SSE 사용 여부보다 작업 상태를 저장하고 다시 조회할 기준이 필요했다.
/v1/runs는 무엇인가
그래서 Hermes의 /v1/runs를 쓰는 쪽으로 방향을 바꿨다.
여기서 말하는 /v1/runs는 Hermes가 제공하는 실행 API다. 이 API는 assistant 응답 스트림을 바로 돌려주는 구조에서 한 단계 더 나아가, 에이전트 작업을 하나의 실행 단위로 만들고 그 실행에서 발생하는 이벤트를 따라갈 수 있게 해준다.

Chat Completions는 “메시지를 보내고 응답 스트림을 받는” 구조다. Runs API는 “실행을 만들고 그 실행에서 발생하는 이벤트를 따라가는” 구조다.
흐름은 두 단계로 나뉜다. POST /v1/runs로 실행을 만들고 run_id를 받는다. 그다음 GET /v1/runs/{run_id}/events로 해당 실행에서 발생하는 이벤트를 읽는다.
여기서 중요한 개념은 네 가지다.
run은 에이전트 작업 하나다. 사용자의 요청을 처리하는 실행 단위다.
run_id는 그 작업을 다시 찾기 위한 식별자다. 브라우저 연결이 끊겨도 작업 자체를 가리키는 이름이 남는다.
events는 작업 중 발생하는 상태 변화다. 텍스트 조각, 도구 실행, 승인 대기, 완료, 실패 같은 정보를 담을 수 있다.
seq나 Last-Event-ID는 클라이언트가 어디까지 이벤트를 봤는지 기억하기 위한 순서값이다. 연결이 끊겼을 때 마지막으로 본 이벤트 이후부터 다시 받을 수 있다.
Runs API는 긴 작업에 식별자를 붙이고, 그 상태 변화를 이벤트로 따라가게 해준다.
메시지에서 실행으로 단위를 옮긴다
Open WebUI와 Hermes 사이의 구조도 바뀌었다.
API 경로만 바꿔서는 충분하지 않다. Pipe가 요청을 처리하는 방식도 함께 바뀐다.
기존 방식에서는 Open WebUI 메시지를 Hermes 응답 스트림으로 바로 연결했다. Runs 방식에서는 먼저 run을 만들고, 그 run의 이벤트를 읽어 Open WebUI로 돌려준다.
이 관점으로 바꾸면 브라우저 연결과 에이전트 작업을 분리할 수 있다. SSE 연결이 끊겨도 run_id가 남아 있다면 같은 실행을 다시 찾아갈 수 있다. 작업이 아직 살아 있다면 다시 events endpoint에 붙으면 된다.
run_id는 이 재연결 과정에서 기준값으로 쓰인다.
이벤트 스트림은 텍스트 스트림보다 넓다
Runs API에서 특히 많이 쓰는 부분은 이벤트 스트림이다.
텍스트 스트림은 assistant가 말하는 내용을 조금씩 받는 데 집중한다. 반면 이벤트 스트림은 실행 중에 일어나는 일을 종류별로 받을 수 있다.
예를 들면 이런 이벤트가 온다.
message.delta
tool.started
tool.completed
approval.requested
run.failed
run.completed이벤트가 종류별로 나뉘면 UI 처리도 자연스러워진다.
message.delta는 assistant 응답 텍스트로 보여주면 된다. tool.started는 “도구 실행 중” 상태로 보여줄 수 있다. tool.completed는 접힌 로그나 완료 상태로 바꿀 수 있다. approval.requested는 승인 UI로 이어질 수 있다. run.failed는 에러 상태로 처리하고, run.completed는 응답 종료로 처리하면 된다.
이렇게 나누면 도구 실행 상태를 assistant message 본문에 섞지 않아도 된다.
이 차이는 UI를 만들 때 중요하다. 도구 실행 로그를 assistant message 안에 섞어 넣으면 나중에 그 메시지를 다시 읽을 때도 로그가 본문처럼 남는다. 반대로 상태 이벤트로 다루면, 화면에서는 진행 상황을 보여주되 최종 대화 기록에는 필요한 답변만 남길 수 있다.
Open WebUI Pipe는 번역 계층이 된다
Open WebUI가 Hermes의 내부 이벤트 모델을 그대로 알 필요는 없다. Hermes도 Open WebUI의 UI 구조를 직접 알 필요가 없다. 그 사이에 Pipe가 있다.
단순 프록시만으로도 연결은 된다. 이 경우 Pipe는 Open WebUI의 요청을 Hermes로 보내고, Hermes의 응답을 다시 돌려준다.
하지만 /v1/runs를 쓰기 시작하면 Pipe에서 처리할 일이 늘어난다. Pipe는 Open WebUI의 채팅 UI 모델과 Hermes의 에이전트 실행 모델을 서로 맞춰줘야 한다.
Pipe가 하는 일은 대략 이렇다. Open WebUI의 chat message를 Hermes의 run 생성 요청으로 바꾸고, message.delta는 assistant 응답 텍스트로 흘려보낸다. tool.started와 tool.completed는 status event로 바꾸고, approval.requested는 승인 UI로 넘길 수 있는 이벤트로 다룬다. run.failed는 에러로 처리하고, run.completed는 스트림 종료로 처리한다.
또 하나 중요한 일은 run_id를 저장하는 것이다. 같은 Open WebUI 메시지에 대해 Pipe가 다시 호출되었을 때 매번 새 run을 만들면 안 된다. 이미 실행 중인 run이 있다면 거기에 다시 붙어야 한다.
이 지점에서 Pipe는 Open WebUI의 요청 형식과 Hermes의 실행 이벤트 형식을 맞추는 어댑터 역할을 한다.
run_id를 어디에 저장할 것인가
재연결을 생각하면 run_id를 어디에 저장할지도 중요해진다.
가장 쉬운 방법은 Pipe 프로세스 메모리에 저장하는 것이다.
chat_id + message_id → hermes_run_id이 방식은 간단하다. 처음 실험하기 좋고, Open WebUI 백엔드 프로세스가 살아 있는 동안에는 브라우저 새로고침이나 스트림 재시도에도 어느 정도 대응할 수 있다.
하지만 한계도 분명하다. Pipe 프로세스가 재시작되면 매핑이 사라진다. 그러면 예전에 시작한 Hermes run이 남아 있어도 Open WebUI 쪽에서는 그 run을 다시 찾기 어렵다.
지금처럼 실험하는 단계에서는 메모리 매핑만으로도 흐름을 확인할 수 있다. 하지만 재연결을 진짜 기능으로 만들려면 Open WebUI의 메시지 metadata에 run_id와 마지막 event sequence를 저장하는 쪽으로 가야 한다. 그러면 대화 기록과 실행 기록의 연결이 남는다. 브라우저를 새로고침하거나 백엔드가 재시작된 뒤에도 복구할 가능성이 생긴다.
재연결을 안정적으로 처리하려면 run_id를 Pipe 프로세스 메모리에만 두지 말고, Open WebUI의 대화 데이터와 함께 저장해야 한다.
물론 이 선택은 구현을 더 복잡하게 만든다. Open WebUI의 metadata 저장 방식에 더 깊게 들어가야 하고, 마지막으로 본 event sequence를 언제 갱신할지도 정해야 한다. 하지만 “연결이 끊겨도 작업을 다시 따라간다”는 기능을 제대로 만들려면 피하기 어려운 부분이다.
체감 응답속도가 좋아진 이유
구조를 바꾸고 나니 체감 응답속도가 빨라졌다.
체감 응답속도가 좋아진 이유는 모델 성능 변화보다 피드백 시점의 변화에 있다. 실제 추론 시간과 도구 실행 시간은 거의 그대로지만, 사용자가 더 이른 시점에 진행 상태를 받는다.
Chat Completions 방식에서는 assistant 응답 텍스트가 나오기 전까지 화면이 조용할 수 있다. 에이전트가 초반에 파일을 읽거나 도구를 실행하는 중이라면 첫 토큰은 늦게 온다. 사용자는 그 시간 동안 응답이 멈춘 것으로 판단한다.
Runs API에서는 첫 assistant 텍스트보다 먼저 첫 이벤트가 올 수 있다. 최종 답변이 완성되기 전에도 작업이 시작되었다는 신호를 받을 수 있다. 도구가 실행 중이라는 상태를 보여줄 수 있고, 어떤 단계까지 왔는지도 대략 알 수 있다.
차이는 한 그림으로 볼 수 있다.
전체 완료 시간이 같아도 Runs 방식에서는 사용자가 더 빨리 반응을 받는다. 아무 표시 없이 기다리는 시간이 줄어들기 때문이다.
모델 추론 시간은 그대로이고, 피드백 없이 기다리는 구간이 줄어든다.
좋아진 점과 남은 문제
이 구조로 바꾸면서 몇 가지 문제가 줄었다.
작업과 연결을 분리할 수 있다. 브라우저 연결이 끊겨도 run_id로 다시 따라갈 수 있다. 도구 실행 상태는 텍스트 본문에 섞지 않고 이벤트로 처리한다. 최종 답변이 완성되기 전에도 run.started나 tool.started 같은 이벤트를 보여줄 수 있어 첫 피드백까지의 시간이 짧아진다. assistant 텍스트가 중간에 끊긴 것인지, run 자체가 실패한 것인지, 사용자가 승인을 해야 하는 상태인지도 이벤트 타입으로 구분할 수 있다.
그렇다고 문제가 전부 사라지지는 않는다.
Open WebUI는 기본적으로 채팅 UI에 맞춰져 있다. 도구 실행 로그를 얼마나 보기 좋게 보여줄지는 Pipe 구현에 달려 있다. 승인 요청을 버튼으로 처리하려면 별도 설계가 필요하다. run_id 저장 전략이 약하면 재연결도 반쪽짜리가 된다.
그리고 이벤트 변환 계층이 생긴다는 것은 구현 복잡도가 올라간다는 뜻이기도 하다. 단순히 OpenAI 호환 endpoint 하나를 등록하는 것보다는 손이 더 간다.
Pipe 쪽 구현은 복잡해진다. 실행 상태를 이벤트 처리 코드로 옮기면 assistant message 문자열에 도구 상태를 섞어 넣지 않아도 된다.
Runs API를 써도 Open WebUI가 곧바로 에이전트 전용 UI로 바뀌지는 않는다. Pipe에서 이벤트 매핑, 상태 표시, 재연결 처리를 계속 구현해야 한다.
채팅 응답에서 실행 상태로 다루는 단위 옮기기
OpenAI 호환 Chat Completions API는 여전히 좋은 기본값이다. Open WebUI에 일반 모델 서버를 붙일 때는 /v1/chat/completions로 충분하다. 짧은 assistant 응답만 필요하고, 도구 실행 상태를 UI에 표시하지 않아도 되고, 브라우저 새로고침 뒤에 기존 작업을 다시 따라갈 필요가 없다면 구조를 더 늘릴 이유가 없다.
Hermes를 붙일 때는 조건이 달라진다. 도구 실행이 오래 걸리고, 중간 상태를 사용자에게 보여줘야 하고, 승인 단계가 있고, 연결이 끊긴 뒤에도 같은 작업을 다시 따라가야 한다면 실행 단위를 따로 둬야 한다.
이렇게 나누면 각 구성요소가 맡는 일이 달라진다. Open WebUI는 대화 화면과 기록을 맡는다. Hermes는 도구를 쓰는 에이전트 실행을 맡는다. Pipe는 Open WebUI의 채팅 메시지를 /v1/runs 요청으로 바꾸고, run event를 다시 응답 텍스트와 상태 표시로 나눈다.
구현은 한 겹 더 복잡해진다. run_id를 저장해야 하고, 마지막으로 읽은 event sequence를 기억해야 하고, Open WebUI가 이해할 수 있는 형태로 상태를 바꿔줘야 한다. 작은 채팅 프록시에는 과한 구조다. 도구 실행, 중간 상태, 재연결이 필요한 에이전트에는 이 처리가 필요하다.
부록: 실제 Pipe 파일과 설정 방법
글에서 말한 Pipe는 파이썬 파일 하나로 만들었다. 전체 코드는 380줄 정도라 본문에 전부 넣으면 글의 흐름을 끊는다. 전체 파일은 Gist에 올려두고, 여기서는 구조와 설정 방법만 정리한다.
코드: open_webui_hermes_runs_pipe.py
GistOpen WebUI Pipe for Hermes Agent /v1/runsOpen WebUI Pipe for Hermes Agent /v1/runs. GitHub Gist: instantly share code, notes, and snippets.https://gist.github.com/clroot/7166a57ca3f6b5e55443a360aa8d3303
Open WebUI에서는 Admin Panel의 Functions 메뉴에서 새 Function을 만들고, 위 파이썬 파일을 붙여 넣으면 된다. 이 파일은 class Pipe를 정의한다. Open WebUI는 이 클래스를 읽어서 하나의 모델처럼 보여준다.
class Pipe:
class Valves(BaseModel):
HERMES_API_BASE_URL: str = Field(
default_factory=lambda: os.getenv("HERMES_API_BASE_URL", "http://127.0.0.1:8642"),
description="Hermes API Server base URL. May include or omit /v1.",
)
HERMES_API_KEY: str = Field(
default_factory=lambda: os.getenv("HERMES_API_KEY") or os.getenv("API_SERVER_KEY", ""),
description="Bearer token matching Hermes API_SERVER_KEY.",
)
MODEL_ID: str = Field(default="hermes-agent")
MODEL_NAME: str = Field(default="Hermes Agent (/v1/runs)")
SHOW_TOOL_STATUS: bool = Field(default=True)설정해야 하는 값은 많지 않다.
HERMES_API_BASE_URL은 Hermes API Server 주소다. 로컬에서 Open WebUI와 Hermes가 같은 머신에서 돈다면 보통 http://127.0.0.1:8642로 충분하다. 컨테이너 안의 Open WebUI에서 호스트의 Hermes로 붙는다면 Docker나 Kubernetes 네트워크에서 호스트를 가리킬 수 있는 주소를 넣어야 한다.
HERMES_API_KEY는 Hermes 쪽 API_SERVER_KEY와 같은 값이다. Hermes API Server에 키를 걸어두었다면 Pipe도 같은 Bearer token을 보내야 한다.
Hermes 쪽에서는 API Server를 켜둔다. 환경 변수로 설정한다면 대략 이런 모양이다.
API_SERVER_ENABLED=true
API_SERVER_HOST=127.0.0.1
API_SERVER_PORT=8642
API_SERVER_KEY=원하는_긴_랜덤_문자열외부 컨테이너나 다른 장비에서 붙어야 한다면 API_SERVER_HOST를 0.0.0.0으로 연다. 다만 이 경우에는 반드시 API_SERVER_KEY를 설정해야 한다. 에이전트 API는 파일, 터미널, 브라우저 같은 도구 실행으로 이어질 수 있으므로 인증 없이 네트워크에 노출하면 안 된다.
Pipe에서 볼 부분은 세 군데다.
첫째, Open WebUI의 messages를 Hermes run 요청으로 바꾼다. system message는 instructions로 모으고, 마지막 user message는 input으로 보내고, 이전 대화는 conversation_history로 넘긴다.
payload = {
"model": self._model_id_from_body(body),
"input": user_message,
"conversation_history": conversation_history,
}둘째, Hermes에 run을 만들고 run_id를 저장한다.
response = await client.post(
f"{base}/v1/runs",
json=payload,
headers=self._headers(extra_headers),
)
data = response.json()
run_id = data["run_id"]셋째, 해당 run의 이벤트를 따라가면서 Open WebUI가 이해할 수 있는 출력으로 바꾼다.
url = f"{base}/v1/runs/{run_id}/events"
async with client.stream("GET", url, headers=headers) as response:
async for line in response.aiter_lines():
...message.delta는 일반 텍스트로 흘려보낸다. tool.started나 tool.completed는 Open WebUI status event로 바꾼다. run.completed, run.failed, run.cancelled 같은 이벤트가 오면 스트림을 끝낸다.
if event_type == "message.delta":
yield str(delta)
if event_type == "tool.started":
yield {"event": {"type": "status", "data": {"description": description, "done": False}}}이 구현은 최소한의 어댑터다. 특히 run_id 매핑은 Pipe 프로세스 메모리에 저장한다. 그래서 브라우저 새로고침이나 같은 백엔드 프로세스 안의 재시도에는 대응할 수 있지만, Open WebUI 백엔드가 재시작되면 매핑이 사라진다. 본격적으로 쓰려면 이 값을 Open WebUI 메시지 metadata에 저장하는 쪽으로 확장하는 것이 맞다.
설정 과정을 순서대로 쓰면 이렇다.
1. Hermes API Server를 켠다.
2. API_SERVER_KEY를 설정한다.
3. Open WebUI Functions에 open_webui_hermes_runs_pipe.py를 추가한다.
4. Function의 Valves에서 HERMES_API_BASE_URL과 HERMES_API_KEY를 맞춘다.
5. Open WebUI의 모델 목록에서 Hermes Agent (/v1/runs)를 선택해 테스트한다.
여기까지 구현하면 Open WebUI에서 Hermes run을 만들고 이벤트를 받아볼 수 있다. 이후에는 run_id 저장 위치와 승인 UI 처리를 보강하면 된다.