Vessel 发布本地优先的 LLM 流量观测代理
spenceclark/Vessel
Vessel 是一个本地优先的 LLM 流量观测代理,把客户端的 base_url 指向这个单文件程序即可捕获、搜索、回放并对比请求,数据只存在本机。它支持 OpenAI Chat/Responses、Anthropic Messages、Ollama 等后端,可按路径或 X-Vessel-Backend 头路由,并用标签和会话归属流量。
The lightweight, local-first observability proxy for LLM traffic. Point a client’s
base_urlat one small binary and get capture, search, metrics, replay, Compare, and a private UI. Captured traffic is stored only on your machine; prompts are forwarded only to the model providers you configure.
Quickstart
-
Install and run
vessel:# macOS (Apple Silicon) and Linux — Homebrew 6 requires trusting a third-party tap first brew trust spenceclark/tap brew install spenceclark/tap/vessel # Windows winget install spenceclark.Vessel # Windows (Scoop) scoop bucket add spenceclark https://github.com/spenceclark/scoop-bucket scoop install vessel
Or download the archive for your OS from Releases, extract it, and run
vessel(orvessel.exe). A first run creates the config and openshttp://127.0.0.1:4550/vessel/(--no-openskips the browser). The default backend is Ollama onlocalhost:11434; if nothing is listening there, that first run opens on the backend picker so you can add OpenAI, Claude, or another backend straight away. -
Point a client at Vessel. Your first request appearing in the UI means it worked.
# Ollama CLI OLLAMA_HOST=127.0.0.1:4550 ollama run llama3.2 # OpenAI SDK — use base_url="http://127.0.0.1:4550/b/openai/v1" # Anthropic SDK — use base_url="http://127.0.0.1:4550/b/anthropic" # curl (Ollama-native) curl http://127.0.0.1:4550/api/chat -d '{"model":"llama3.2","messages":[{"role":"user","content":"hello"}],"stream":false}'
Vessel is a foreground process: its terminal is Vessel, so closing the terminal stops capture. For always-on use, use your OS’s normal mechanism (Task Scheduler, systemd, or launchd).
Homebrew and Scoop installs skip the prompts below. If you run the downloaded archive instead: macOS 15+ (Sequoia) removed right-click → Open as a Gatekeeper bypass, so first unblock the binary from Terminal:
xattr -d com.apple.quarantine ./vessel
If that doesn't work, xattr -c ./vessel clears all extended attributes. Alternatively,
use the GUI path: run ./vessel, let it be blocked, then go to System Settings →
Privacy & Security and click Open Anyway, then run it again and click Open. On
Apple Silicon, if you still see Killed: 9 after un-quarantining, ad-hoc-sign the binary
with codesign -s - --force ./vessel.
Windows may show SmartScreen, where More info then Run anyway is needed. Signing/notarization is intentionally deferred.
Features
- Captures and searches OpenAI Chat/Responses, Anthropic Messages, Ollama, and unknown traffic; live history, sessions and tags, filters, backend health, warnings, and light/dark themes.
- Replay any captured request against another backend or model — or fan it out across several models or parameter values — and compare every response and its metrics beside the original. Score responses 1–5; Reports ranks models and parameter sets by score.
- Reports: tokens, requests, tok/s, duration, cache efficiency, and warnings by model, tag, or backend, plus context growth over a session. Export any filtered list to CSV or JSONL.
- Copy as curl and a read-only MCP endpoint (
/vessel/mcp) for searches, request detail, stats, and sessions from your own AI tools.
Routing and tags
Route any request to any configured backend — per request, with no client reconfiguration. Any OpenAI-compatible server (LM Studio, llama.cpp, vLLM, …) is just a backend entry away:
# by path — works with any client that can set a base URL base_url = "http://127.0.0.1:4550/b/lmstudio/v1" # by header — for your own code curl http://127.0.0.1:4550/api/chat -H "X-Vessel-Backend: ollama" -d '...'
Tag requests to attribute traffic — for example, one tag per agent in a multi-agent app — then filter, search, and compare by tag in the UI:
curl http://127.0.0.1:4550/api/chat -H "X-Vessel-Tags: DungeonMaster,run-42" -d '...' # or the path form, for header-less clients: /t/DungeonMaster/api/chat
Assign a request to a named run with X-Vessel-Session (created on first use). This
does not change the Reset-driven session used by headerless traffic:
curl http://127.0.0.1:4550/api/chat -H "X-Vessel-Session: run-42" -d '...'
Session names are limited to 128 characters, and named sessions are capped at 500 markers: at that cap, requests for unseen names are captured in the current session (Vessel logs a warning when this happens), while existing named sessions continue to work. (Reset-created markers are not subject to this cap.)
Both compose: /b/ollama/t/planner/api/chat. Routing precedence is /b/{backend}/…,
then X-Vessel-Backend, then the default backend. Vessel strips its own X-Vessel-*
headers before forwarding — backends never see them.
From LangChain / LangGraph
LangChain chat models don't expose per-request headers, but nothing above needs them. Per-agent tags ride on the path — one model instance per agent:
planner = init_chat_model('ollama:llama3.2', base_url='http://127.0.0.1:4550/t/planner')
For per-run sessions (and node tags from one shared model), stamp the outgoing request
from a callback. LangGraph puts the node name in run metadata; put the thread_id there
too, and it becomes the Vessel session:
import contextvars, httpx from langchain_core.callbacks import BaseCallbackHandler _vessel: contextvars.ContextVar[dict | None] = contextvars.ContextVar('vessel', default=None) class VesselCallback(BaseCallbackHandler): def on_chat_model_start(self, serialized, messages, *, metadata=None, **kwargs): md = metadata or {} _vessel.set({k: v for k, v in { 'X-Vessel-Tags': md.get('langgraph_node'), 'X-Vessel-Session': md.get('thread_id'), }.items() if v}) def _stamp(request: httpx.Request): request.headers.update(_vessel.get() or {}) llm = init_chat_model('ollama:llama3.2', base_url='http://127.0.0.1:4550', client_kwargs={'event_hooks': {'request': [_stamp]}}) thread_id = str(uuid.uuid4()) graph.invoke(state, config={'configurable': {'thread_id': thread_id}, 'metadata': {'thread_id': thread_id}, 'callbacks': [VesselCallback()]})
ChatOpenAI takes the hook as http_client=httpx.Client(event_hooks=…) instead of
client_kwargs; for ainvoke, give the async client an async def hook. A complete
four-agent LangGraph example is in docs/examples/langgraph.py.
From Pydantic AI
Pydantic AI sends per-request headers through extra_headers in model settings, so tags
and sessions are plain headers — set them on each agent, even when agents share a model:
session_id = str(uuid.uuid4()) model = OpenAIChatModel('llama3.2', provider=OllamaProvider(base_url='http://127.0.0.1:4550/v1')) planner = Agent(model, model_settings={'extra_headers': { 'X-Vessel-Session': session_id, 'X-Vessel-Tags': 'planner'}})
Settings merge shallowly — model, then agent, then run(..., model_settings=…) — so a
later extra_headers replaces an earlier one rather than adding to it. Send the session
and tag together at whichever level you set them: a new session per run means passing
both headers to run. A two-agent example (one delegating to the other) is in
docs/examples/pydantic_ai_example.py.
Query your traffic from AI tools (MCP)
Vessel serves a read-only MCP endpoint, so tools like Claude Code can search and inspect your captured traffic — "why did my planner agent stall this afternoon?" answered by the agent querying Vessel directly:
claude mcp add --transport http --scope user vessel http://127.0.0.1:4550/vessel/mcp
--scope user makes it available in every project (omit it to register for the
current folder only). The endpoint exposes search, request detail, stats, and
sessions — read-only, and any MCP client you connect can read your captured prompts.
Disable it with mcp.enabled: false.
Captured requests and sessions are also MCP resources (vessel://requests/{id},
vessel://sessions/{id}), so clients that support resources (such as Claude Code's
@ mentions) can attach one to a prompt directly. The 20 most recent requests and 10
most recent sessions are listed; older ones are reachable by URI.
Container / compose
For Docker users, the shipped compose.yaml is the canonical setup:
docker compose up -d
It runs ghcr.io/spenceclark/vessel, persists state in vessel-data, and assumes Ollama
runs on the host. In Open WebUI, set OLLAMA_BASE_URL=http://vessel:4550 when Open WebUI
runs in the same compose stack (use http://localhost:4550 from a host-side Open WebUI);
the path is Open WebUI → Vessel → Ollama. For bare Docker, host.docker.internal is the
default backend host name (and is supplied by the compose file’s host-gateway mapping).
To run Ollama in the same compose stack, uncomment the commented service in
compose.yaml, then change Vessel’s backend baseUrl to http://ollama:11434.
Configuration
--config <path> always wins. Otherwise, an existing vessel.json beside the executable
selects portable mode; a fresh download creates one under %LOCALAPPDATA%\vessel-proxy
(Windows), ~/.config/vessel-proxy (Linux/XDG), or
~/Library/Application Support/vessel-proxy (macOS). vessel.db lives alongside it.
vessel --help prints the resolved paths; --version prints the build; --no-open skips
the first-run browser.
| Field | Meaning |
|---|---|
listen |
host:port; defaults to 127.0.0.1:4550 (or 0.0.0.0:4550 in a container). |
defaultBackend |
Backend name used when no route selector is supplied. |
backends.<name>.baseUrl |
Required HTTP(S) backend URL. |
backends.<name>.type |
ollama, openai, anthropic, or auto; informs parsing and replay. |
backends.<name>.injectStreamUsage |
For OpenAI backends, request exact streamed usage; off by default. |
backends.<name>.authEnv |
Optional process environment variable used only for replay credentials. |
timeouts.activitySeconds |
Maximum no-byte-movement interval (default 1800). |
retention.maxRequests / retention.maxDbSizeMb |
Local history caps (defaults 10000 / 500). |
capture.maxBodyMb |
Per-body capture cap (default 32); forwarding is never truncated. |
warnings.slowTtftMs |
Slow-TTFT threshold in ms (default 5000); 0 disables it. |
warnings.slowResponseMs |
Slow non-streamed response threshold in ms (default 120000); 0 disables it. |
mcp.enabled |
Enables the read-only MCP endpoint (default true). |
Example:
{
"listen": "127.0.0.1:4550",
"defaultBackend": "ollama",
"backends": {
"ollama": { "baseUrl": "http://localhost:11434", "type": "ollama" },
"openai": { "baseUrl": "https://api.openai.com", "type": "openai", "authEnv": "OPENAI_API_KEY" }
}
}Replay and Compare
Open any captured request and choose Replay to send it again — to the same backend,
a different one, or a different model. Replay stays within one wire format: any
OpenAI-compatible backend can stand in for another, but Ollama-native ⇄ Anthropic is not
translated. The one rename that would otherwise make replays fail is applied for you —
max_tokens ↔ max_completion_tokens for OpenAI Chat — and shown as (auto) in the
parameter diff.
A replay can fan out: up to 8 variations in one go, either several models or one
parameter swept across several values (temperature 0.2, 0.7, 1.0). Members run one after
another so timings aren’t polluted by contention, and the dialog says how many requests it
will send — and how many go to keyed backends — before anything goes out. Compare shows
each response beside the original with metric deltas and the parameters that differed.
Score any response 1–5 from Compare (keys 1–5; ←/→ move between columns, 0
clears). Reports ranks models and parameter sets by mean score and how often each came
top. In the request list, ↑/↓ move the selection.
Replays carry the original’s tags, land in the current session, and link back to the
original. Their X-Vessel-Replay-* headers are stripped before forwarding like every
other X-Vessel-* header.
Replay auth
Vessel never stores keys. Replay reads the credential from the environment of the Vessel
process: OPENAI_API_KEY for OpenAI, ANTHROPIC_API_KEY for Anthropic, or the backend’s
authEnv name for another compatible backend.
Reports and export
Reports charts the current scope — session, tags, dates — as tokens, requests, tok/s, and duration by model, tag, or backend; cache efficiency; warnings by type; context growth across a session; and the score leaderboards. Export, in the filter bar, writes exactly the filtered list (the row count is shown first) to CSV, or to JSONL with bodies included if you want them.
Warnings
Rows carry a warning count; the Overview tab names each one.
| Warning | Meaning |
|---|---|
cold_load |
Ollama loaded the model for this request — a slow duration, not slow generation. |
slow_ttft |
Time to first token exceeded warnings.slowTtftMs, and no cold load explains it. |
slow_response |
A non-streamed response took longer than warnings.slowResponseMs. The duration includes any time queued at the backend. |
repetitive_output |
The end of the response is degenerate looping text (e.g. 1/1/1/…). Detection only. |
truncated_response |
The response was cut short by the output limit (length / max_tokens). |
tokens_estimated |
The backend reported no usage; counts are estimated (chars ÷ 4). |
usage_injected |
Vessel added stream_options.include_usage (the opt-in injectStreamUsage). |
tool_call_in_text |
The request declared tools, but the model wrote a tool call as plain text instead of a structured call. Detection only — nothing is rewritten. |
path_missing_v1 |
404 from an OpenAI-compatible backend on a bare SDK path (/chat/completions, /responses, /embeddings, /models, …) with no /v1/ in it — put /v1 in the client’s base_url. Not raised when the backend’s own baseUrl already carries a path. |
stream_incomplete |
A streamed response never reached its terminal marker. |
client_disconnect |
The client went away before the exchange completed. |
body_truncated |
The stored body hit capture.maxBodyMb; forwarding was not truncated. |
http_error / proxy_error |
Non-2xx from the backend / Vessel could not reach it. |
parse_error |
Format detection or parsing failed; the row was kept as raw with bytes intact. |
Privacy and data
Requests are forwarded byte-for-byte — Vessel never modifies traffic beyond
stripping its own X-Vessel-* control headers (the opt-in injectStreamUsage is the
single documented exception). Vessel’s own per-request overhead is measured and shown
on every request in the UI — typically around a millisecond.
vessel.db contains your prompts and responses, stored locally with compressed bodies.
Authorization headers are redacted at rest, and Vessel never stores API keys anywhere.
Vessel binds localhost by default; if you bind a non-loopback address, the UI and
startup log warn that people on the network may read captured prompts (and access MCP
when enabled). Keep retention caps appropriate, and use the UI’s Data panel to clear
history or bulk-delete non-current sessions; individual sessions can be deleted from the
session picker. To remove Vessel completely: delete the executable and the vessel-proxy
data folder — there is nothing else. The full privacy policy covers all of
this in detail.
Building from source
Requires .NET 10 and Node.js.
dotnet build Vessel.sln dotnet test Vessel.sln cd frontend && npm ci && npm test && npm run build && npm run lint dotnet publish src/Vessel -c Release -r win-x64 --self-contained -p:PublishSingleFile=true
See docs/brief.md for the product overview,
docs/architecture.md for the design, and
CONTRIBUTING.md to get involved. Vessel is licensed under
MIT.
来源:GitHub · github.com
