1. 项目概述:为什么需要一个日志代理?
最近在折腾本地大模型,特别是用 Ollama 来跑各种开源模型,体验确实不错。但用久了就发现一个问题:Ollama 自带的日志输出,对于想深入分析模型调用情况、监控性能或者做成本核算来说,信息量远远不够。它默认的日志更像是给开发者看的运行状态,而不是给应用开发者或运维人员用的结构化数据。
举个例子,你调用了一个模型,Ollama 的日志可能会告诉你“请求已处理”,但你很难快速从中提取出:这次调用用了哪个模型?输入了多少 token?输出了多少 token?整个生成过程耗时多久?如果是在一个微服务架构里,有多个服务在调用 Ollama,你更没法区分这些日志分别来自哪个上游服务。这对于想基于使用情况做精细化运营、排查慢请求,或者只是单纯想看看团队里谁最爱“调戏”模型的人来说,就非常不方便。
所以,我动手写了一个Ollama Logging Proxy。顾名思义,它是一个代理服务器,部署在你的应用和 Ollama 服务之间。所有发往 Ollama 的 API 请求(比如/api/generate,/api/chat)都会先经过这个代理。代理在转发请求的同时,会以一种更结构化、更丰富的方式,记录下这次调用的所有关键信息,然后把这些日志推送到你配置好的地方,比如控制台、文件,或者更专业的日志收集系统如 Loki、Elasticsearch 里。
这个项目我已经完全开源了。它不是一个重型的企业级解决方案,而是一个轻量、可插拔的工具,旨在解决一个非常具体且普遍的痛点:让 Ollama 的调用变得可观测。如果你也在用 Ollama,并且苦于日志不够用,那么这个代理或许能帮上忙。
2. 核心设计思路与架构拆解
2.1 核心需求与设计目标
在设计这个代理之前,我梳理了几个核心需求,这些需求也直接决定了最终的架构和技术选型:
- 透明代理,零侵入:这是最重要的原则。使用这个代理,不应该要求你修改任何现有的应用代码。你只需要把原来指向 Ollama 服务地址(例如
http://localhost:11434)的配置,改成指向代理的地址即可。代理必须完全兼容 Ollama 的 REST API,确保所有请求都能被正确转发和响应。 - 结构化日志,信息丰富:日志不能只是一行文本。它必须是一个结构化的 JSON 对象,包含对分析有用的所有维度。这至少包括:请求时间戳、客户端 IP、调用的 API 路径、使用的模型名称、请求的原始 prompt、生成的完整响应、输入/输出的 token 数量、请求总耗时、以及任何可能的错误信息。
- 灵活的输出后端:不同场景下对日志的处理方式不同。在开发调试时,可能直接输出到控制台(stdout)就够了;在生产环境,可能需要写入文件供 Filebeat 采集,或者直接通过 HTTP 发送到远端的日志平台。代理需要支持多种输出方式,并且易于扩展。
- 高性能与低延迟:代理作为所有请求的必经之路,绝对不能成为性能瓶颈。日志的记录和转发操作必须是异步的,不能阻塞正常的请求响应流程。代理本身的内存和 CPU 占用也要尽可能低。
- 易于部署与配置:最好能通过 Docker 一键部署,配置文件清晰明了,不需要复杂的依赖和设置。
基于这些目标,我选择了 Go 语言作为实现语言。Go 的并发模型(goroutine)非常适合处理高并发的网络代理场景,其标准库对 HTTP 的支持非常完善,编译后是单个二进制文件,部署极其简单,运行时资源消耗也低。
2.2 系统架构与数据流
整个代理的架构非常清晰,可以看作一个管道(Pipeline)处理模型。
[你的应用程序] --> (HTTP Request) --> [Ollama Logging Proxy] --(1. 记录日志)--> [日志输出后端] | --(2. 转发请求)--> [上游 Ollama 服务] | <--(3. 返回响应)-- [上游 Ollama 服务] | --(4. 记录响应日志)--> [日志输出后端] | [你的应用程序] <-- (HTTP Response) <--- 请求拦截与日志记录:代理监听一个 HTTP 端口。当你的应用发起请求时,代理首先接收到这个请求。在这一刻,它会立即提取并记录“请求日志”。这部分日志包含了请求的元信息(如 URL、Headers、客户端 IP)以及请求体(Body)。对于
/api/generate和/api/chat这类流式响应接口,请求体里就包含了model和prompt等关键信息。 - 请求转发:代理将原始的 HTTP 请求几乎原封不动地转发给配置好的上游 Ollama 服务。
- 响应接收:代理接收来自 Ollama 的响应。这里需要特别注意,Ollama 的生成接口是 Server-Sent Events (SSE) 流式响应。代理必须能够正确处理这种流,一边将数据块(chunk)返回给客户端,一边累积完整的响应内容。
- 响应日志记录:当响应流结束或完成时,代理开始记录“响应日志”。这时,它已经拥有了完整的响应内容,可以从中计算出输出 token 的数量(如果响应是 JSON 格式,可以直接解析;如果是纯文本,可能需要估算)。同时,它会计算从请求开始到响应结束的总耗时。最后,将“请求日志”和“响应日志”合并成一个完整的“调用日志”记录,发送给配置的输出后端。
这个架构的关键在于异步非阻塞。无论是请求日志还是响应日志的记录动作,都不会阻塞请求转发的核心路径。我使用了一个带缓冲的 Channel 来接收日志条目,然后由后台的 worker goroutine 负责将日志写入到各个配置的输出端。这样即使某个输出后端(比如网络存储)暂时变慢,也不会影响代理对请求的转发。
3. 核心功能实现与配置详解
3.1 日志数据结构的定义
日志的价值首先在于其包含的信息。我定义了一个结构体LogEntry,它囊括了一次模型调用中所有我认为有价值的信息。
type LogEntry struct { Timestamp time.Time `json:"timestamp"` // 请求开始时间 Duration int64 `json:"duration_ms"` // 总耗时,毫秒 ClientIP string `json:"client_ip"` // 客户端IP地址 Method string `json:"method"` // HTTP方法,如 POST Path string `json:"path"` // 请求路径,如 /api/generate Model string `json:"model"` // 调用的模型名称,如 “llama3.2:1b” Prompt string `json:"prompt"` // 用户输入的提示词(可能截断) FullResponse string `json:"response"` // 模型生成的完整响应(可能截断) InputTokens int `json:"input_tokens"` // 输入token数(估算或从响应解析) OutputTokens int `json:"output_tokens"` // 输出token数(估算或从响应解析) StatusCode int `json:"status_code"` // HTTP状态码,如 200, 500 Error string `json:"error,omitempty"`// 错误信息(如果发生) RequestID string `json:"request_id"` // 本次请求的唯一ID,用于追踪 // ... 可能还有其他自定义标签,如来自HTTP头的 `X-User-ID` }几个关键字段的获取方式:
InputTokens/OutputTokens:这是最有价值但也最棘手的部分。Ollama 的响应流中,每个数据块(chunk)有时会包含一个"prompt_eval_count"和"eval_count"字段,分别代表输入和已生成的 token 数。代理会在流式响应结束时,尝试从最后一个有效 chunk 中提取这些值。如果提取不到(比如某些模型或配置下不返回),则会回退到使用一个简单的分词器(如基于空格和常见标点)进行粗略估算。虽然估算不精确,但对于趋势分析和相对比较来说,已经足够有用。Prompt和FullResponse:出于隐私和日志体积考虑,这两个字段在记录时会被截断。默认配置下,只保留前500个字符。你可以在配置文件中调整这个长度,或者完全关闭截断功能。RequestID:代理会自动为每个请求生成一个 UUID。同时,它也会检查请求头中是否包含X-Request-ID这类常用于链路追踪的字段。如果存在,则会优先使用上游传递过来的 ID,这样可以很方便地将代理的日志与你业务系统的其他日志关联起来。
3.2 输出后端(Exporter)的实现
为了灵活性,我采用了插件化的输出设计。定义了一个Exporter接口:
type Exporter interface { Export(entry *LogEntry) error Name() string }目前实现了三种最常用的输出后端:
- Console Exporter(控制台输出器):将格式化的 JSON 日志打印到标准输出。这是开发调试时的首选。你可以直接用
docker logs或者journalctl来查看日志。 - File Exporter(文件输出器):将日志以 JSON Lines 格式(每行一个 JSON 对象)写入指定的文件。它支持日志轮转(log rotation),可以按时间或文件大小自动切分和归档旧日志,避免单个文件过大。
- HTTP Exporter(HTTP输出器):将日志通过 HTTP POST 请求,以 JSON 数组批次的形式,发送到指定的远端 URL(例如你的 Loki、Elasticsearch 或自定义日志收集服务的入口)。这是生产环境集成的最佳方式。它内置了重试机制和简单的断路器,在网络波动或下游服务暂时不可用时,能避免日志丢失或代理自身崩溃。
配置示例 (config.yaml):
server: listen_addr: ":8080" # 代理监听的地址 upstream_url: "http://host.docker.internal:11434" # 上游Ollama服务地址 logging: truncate_length: 500 # 截断Prompt和Response的长度 exporters: - name: "console" # 输出到控制台 - name: "file" path: "./logs/ollama-proxy.log" # 输出到文件 max_size_mb: 100 # 单个日志文件最大100MB max_backups: 5 # 保留5个归档文件 - name: "http" url: "http://your-loki-server:3100/loki/api/v1/push" # 输出到Loki batch_size: 10 # 每10条日志批量发送一次 timeout_seconds: 5你可以同时启用多个输出器,日志会被复制到每一个启用的后端。这种设计让你可以轻松地将日志同时用于实时调试(控制台)和长期存储分析(文件或远程服务)。
3.3 流式响应(SSE)的处理要点
处理 Ollama 的流式响应是这个代理实现中的一个技术重点,也是容易出坑的地方。
Ollama 的/api/generate和/api/chat接口返回的是text/event-stream格式的数据。代理不能等流完全结束再返回给客户端,那样就失去了“流式”的意义,用户体验会变得极差。正确的做法是:代理自身也要作为一个流式响应的中间件。
我的实现逻辑是:
- 代理收到客户端请求后,立即向上游 Ollama 发起请求。
- 一旦收到 Ollama 的响应头(确认是 SSE 流),代理就立刻设置好客户端的响应头(
Content-Type: text/event-stream),并开始向客户端回写数据。 - 代理创建一个缓冲区(
bytes.Buffer)来累积上游返回的每一个数据块(chunk)。同时,它将这些 chunk 几乎实时地转发给客户端。 - 当上游流关闭(即读到
io.EOF)时,代理知道响应结束了。此时,缓冲区里已经保存了完整的响应内容。代理会解析这个完整内容,提取 token 数量、最终生成的文本等信息,然后生成最终的LogEntry,异步送入日志处理通道。 - 最后,代理关闭与客户端的连接。
这里有一个非常重要的细节:错误处理。如果在流式传输过程中,客户端提前断开连接(比如用户关闭了浏览器),那么代理向客户端写入数据会失败。此时,代理应该立即终止从上游 Ollama 读取数据,并取消本次请求,否则会浪费宝贵的计算资源。这需要通过 Go 的context.Context来实现双向取消。
4. 部署、配置与实战操作指南
4.1 使用 Docker 快速部署
这是最推荐的部署方式,避免了环境依赖问题。
- 准备配置文件:在宿主机上创建一个目录,例如
/opt/ollama-proxy,在里面创建config.yaml文件,内容参考上一节的示例。关键是upstream_url要指向你真实的 Ollama 服务。如果 Ollama 和代理都跑在 Docker 里,可能需要用host.docker.internal(Mac/Windows Docker Desktop)或172.17.0.1(Linux Docker 默认网桥网关)来指向宿主机。 - 运行容器:
docker run -d \ --name ollama-logging-proxy \ -p 8080:8080 \ # 将容器的8080端口映射到宿主机的8080端口 -v /opt/ollama-proxy/config.yaml:/app/config.yaml \ # 挂载配置文件 -v /opt/ollama-proxy/logs:/app/logs \ # 挂载日志目录(如果使用文件输出) your-docker-username/ollama-logging-proxy:latest - 测试代理:运行后,你的代理就在
http://localhost:8080上运行了。将你应用程序中连接 Ollama 的地址从原来的http://localhost:11434改为http://localhost:8080,然后发起一次测试请求。查看容器日志docker logs ollama-logging-proxy,你应该能看到结构化的 JSON 日志输出。
4.2 与现有系统的集成示例
场景:集成到 Grafana Loki 进行可视化
假设你已经有一个 Grafana + Loki 的监控栈。
配置 HTTP Exporter:在代理的
config.yaml中,启用 HTTP 输出器,指向 Loki 的推送 API。exporters: - name: "http" url: "http://loki:3100/loki/api/v1/push" batch_size: 50 timeout_seconds: 10 # 可以添加自定义标签,便于在Loki中筛选 extra_labels: app: "ai-assistant" env: "production"Loki 的推送 API 要求特定格式的 JSON。我的 HTTP Exporter 已经内置了将
LogEntry转换为 Loki 格式的逻辑,你只需要提供 URL 和可选的标签即可。在 Grafana 中探索日志:部署并运行一段时间后,打开 Grafana,切换到 Explore 页面,数据源选择 Loki。你可以使用 LogQL 查询语句来筛选和分析日志,例如:
{app="ai-assistant"} |= “llama3.2”:查看所有使用了 llama3.2 模型的请求。{app="ai-assistant"} | json | duration_ms > 5000:查询所有耗时超过5秒的慢请求。sum by (model) (rate({app="ai-assistant”} [5m])):统计过去5分钟内,各个模型的调用频率。
场景:基于日志计算使用成本
许多按 token 计费的云模型 API 让成本核算变得清晰。对于本地模型,虽然硬件成本固定,但了解各模型、各用户或各项目的 token 消耗量,对于资源规划和优化非常有帮助。
你可以将日志导入到任何支持 SQL 查询的系统(如 Elasticsearch 或甚至是一个时序数据库),然后执行类似下面的分析:
-- 统计每个模型过去24小时消耗的总token数 SELECT model, SUM(input_tokens + output_tokens) as total_tokens FROM ollama_logs WHERE timestamp > NOW() - INTERVAL '1 day' GROUP BY model ORDER BY total_tokens DESC; -- 统计每个客户端IP(可近似代表用户或服务)的日均调用次数和平均响应时间 SELECT client_ip, COUNT(*) as call_count, AVG(duration_ms) as avg_duration_ms FROM ollama_logs GROUP BY client_ip;4.3 配置文件详解与调优
配置文件是代理行为的核心控制点。以下是一些关键参数的调优建议:
server.listen_addr:默认:8080。如果你在 Kubernetes 中部署,通常不需要修改,Service 会处理端口映射。server.upstream_url:确保这个地址从代理容器内部可以访问。在复杂的网络环境中(如 Docker Compose 或 K8s),使用服务名(如http://ollama:11434)通常是最可靠的。logging.truncate_length:默认 500。对于调试,你可能需要看到完整的 prompt 和 response,可以将其设置为一个很大的数(如 10000)或 0(表示不截断)。但在生产环境,出于隐私和存储考虑,建议保留截断。exporters:- 文件输出器:
max_size_mb和max_backups控制日志轮转。根据你的日志量调整。如果日志量巨大,考虑使用更专业的日志收集代理(如 Vector, Fluent Bit)来采集文件,而不是让代理直接写 HTTP。 - HTTP 输出器:
batch_size:批量发送的日志条数。增大此值可以提高吞吐量,减少网络请求次数,但会稍微增加内存占用和日志延迟(等待批次满)。对于高吞吐场景,建议设置在 50-100。timeout_seconds:HTTP 请求超时时间。如果网络不稳定或日志服务较慢,可以适当调大。同时,确保你的日志接收服务有足够的处理能力,避免超时导致日志重试和堆积。
- 文件输出器:
5. 常见问题排查与性能优化心得
在实际使用和社区反馈中,我遇到并总结了一些典型问题。
5.1 问题排查清单
| 问题现象 | 可能原因 | 排查步骤与解决方案 |
|---|---|---|
| 应用连接代理失败,报连接拒绝 | 代理服务未启动或监听端口错误 | 1. 检查代理容器/进程是否运行 (docker ps或ps aux)。2. 检查 config.yaml中的listen_addr配置。3. 检查防火墙或安全组规则是否放行了代理端口。 |
| 代理能收到请求,但无法连接到上游 Ollama | upstream_url配置错误或网络不通 | 1. 进入代理容器内部 (docker exec -it <container> sh),使用curl或wget测试upstream_url是否可达。2. 确认 Ollama 服务正在运行且端口正确。 3. 在 Docker 环境中,注意容器间网络通信,使用正确的服务名或主机名。 |
日志中没有input_tokens和output_tokens字段,或值为0 | Ollama 响应中未返回 token 计数信息 | 1. 这是正常现象,取决于模型和 Ollama 版本。代理会尝试解析,解析失败则标记为0。 2. 可以检查原始 Ollama 响应(通过调试或抓包),看是否包含 prompt_eval_count字段。3. 目前代理的估算逻辑较简单,对中文等语言估算不准。社区有改进分词器的计划。 |
| HTTP Exporter 报错,日志发送失败 | 网络问题或下游日志服务故障 | 1. 查看代理日志中的错误信息,确认是网络超时、连接拒绝还是认证失败。 2. 测试从代理容器到日志服务地址的网络连通性。 3. 检查日志服务(如 Loki)的状态和日志,确认其能正常接收数据。 4. 临时启用 Console Exporter,确保日志在生成环节是正常的。 |
| 代理内存使用率缓慢增长 | 可能是日志 Channel 堵塞,导致日志对象堆积 | 1. 检查某个 Exporter(尤其是 HTTP Exporter)是否持续失败。失败会导致重试,日志在缓冲 Channel 中堆积。 2. 调低日志级别或减少不必要字段的采集,减轻处理压力。 3. 适当增大 Channel 的缓冲大小(需修改代码并重新编译),但这只是权宜之计,根本要解决 Exporter 的瓶颈。 |
| 流式响应变慢或中断 | 代理处理流式响应的缓冲区或上下文管理有问题 | 1. 这是一个复杂问题。首先确认直接连接 Ollama 是否正常,排除上游问题。 2. 启用代理的调试日志,观察流式转发过程中是否有错误或延迟。 3. 检查客户端和代理、代理和 Ollama 之间的网络延迟和带宽。代理会引入少量开销,但在良好网络下应不明显。 |
5.2 性能优化与实战心得
关于异步日志的性能取舍:我选择将日志写入操作完全异步化,核心请求路径几乎不受影响。但这带来了“最终一致性”的问题:如果代理进程突然崩溃,缓冲 Channel 中尚未写入的日志可能会丢失。对于绝大多数监控和调试场景,丢失极少量的日志是可以接受的。如果你对日志完整性要求极高(如计费),可以考虑引入更可靠的持久化缓冲队列,如磁盘-backed 的 channel,但这会显著增加复杂度。我的建议是,对于关键业务,确保你的 HTTP Exporter 指向一个高可用的日志服务,并配置好合理的重试机制,这能在性能和可靠性间取得较好平衡。
谨慎截断日志字段:最初我没有做截断,结果一次生成长文的请求,日志文件瞬间暴涨。
Prompt和FullResponse字段是体积大户。但截断又可能把关键的错误信息截掉。我的经验是,在生产环境,开启截断(比如500-1000字符),并确保Error字段不被截断。在开发调试环境,可以关闭截断或设置一个很大的值。更好的做法是,在 HTTP Exporter 的配置中,允许为不同环境设置不同的截断策略。为日志添加业务维度标签:代理默认记录的字段都是技术维度的。但在实际分析时,你往往需要业务维度,比如“这次请求来自哪个用户?”、“属于哪个项目?”。我预留了扩展点:代理会检查传入的 HTTP 请求头,如果发现像
X-User-ID、X-Project-Name这样的自定义头,会自动将它们作为标签(Tags)添加到LogEntry中。因此,在你的应用客户端,可以在调用 Ollama 代理时,附带这些业务头信息,这样最终的日志就会包含丰富的业务上下文,分析价值大大提升。监控代理自身:代理本身也是一个服务,也需要被监控。我建议至少监控两点:一是代理的请求吞吐量和延迟(可以与直连 Ollama 进行对比),二是日志导出队列的积压情况。我在代码中暴露了一个简单的
/metrics端点(兼容 Prometheus 格式),可以输出如ollama_proxy_log_queue_length(当前待处理日志数)这样的指标。你可以用 Prometheus 采集这些指标,并在 Grafana 设置告警,当队列积压超过阈值时发出通知,这通常意味着下游日志系统出现了瓶颈。
开源这个项目后,我收到了很多来自社区的反馈,其中最有价值的是一些关于支持更多输出格式(如直接写入 Kafka)和更精细的日志过滤(例如只记录特定模型的请求)的建议。这些都在后续的迭代计划中。做这个工具的初衷很简单,就是解决自己遇到的一个具体问题。如果你也在用 Ollama,不妨试试看,更欢迎提交 Issue 和 PR,一起让它变得更好用。