[api][python][java] Track embedding token usage metrics#870
[api][python][java] Track embedding token usage metrics#870zxs1633079383 wants to merge 3 commits into
Conversation
weiqingy
left a comment
There was a problem hiding this comment.
Thanks for taking this on — A few things inline.
- The two correctness issues I flagged inline (the Bedrock batch losing metrics, and a thrown
embed()skipping the consume) share one root: the per-thread side channel —ThreadLocalin Java,ContextVarin Python — assumes the connection records usage on the same thread the setup later consumes it on, and that nothing throws between record and consume. Both assumptions hold for the single-text, single-thread happy path, but Bedrock's batch fans work out toembedPoolworkers (record lands on the wrong thread), and a record-then-throw connection leaves the slot populated for the next call. Chat sidesteps both by carrying usage on the response object and recording at the action layer (BaseChatModelSetup.java:118), never a thread-bound channel. Would returning usage alongside the embedding result — a small result-plus-usage value, or usage attached to the response, consumed by the setup from that returned value — be viable here? That would make batch/pooled connections and the exception path correct by construction rather than by convention. I realize the current design deliberately keeps theembed(...)return types unchanged for API compatibility, so this is a real tradeoff, not a free win — curious how you're weighing it.
| if (inputTokenCount != null && inputTokenCount.isNumber()) { | ||
| long tokens = inputTokenCount.asLong(); | ||
| recordTokenUsage(tokens, tokens); | ||
| } |
There was a problem hiding this comment.
This records into the current thread's ThreadLocal, which is right for the single-text path — but this same connection's embed(List) fans a batch of size > 1 out to embedPool workers (CompletableFuture.supplyAsync(() -> embed(text, parameters), embedPool)), so each per-text recordTokenUsage lands on a worker thread while BaseEmbeddingModelSetup.embed(List) consumes on the calling (mailbox) thread. For every Bedrock batch of size > 1 the consume then returns null and the tokens are silently dropped (exactly the RAG add(List<Document>) path #858 targets), while the workers' ThreadLocals are never consumed and — since recordTokenUsage accumulates onto any existing value — grow across batches. The size <= 1 path stays on the calling thread, so it's fine. This is the batch symptom of the side-channel root in the top-level comment; would carrying usage back with the result close it here?
There was a problem hiding this comment.
Resolved in e023346 by removing the ThreadLocal side channel. BedrockEmbeddingModelConnection.embedWithUsage(List) now collects EmbeddingResult<float[]> from the worker futures and merges token usage on the caller path. BedrockEmbeddingModelTest.testBatchEmbeddingAggregatesTokenUsage covers the batch size > 1 path.
| return getConnection().embed(text, params); | ||
| BaseEmbeddingModelConnection currentConnection = getConnection(); | ||
| float[] embedding = currentConnection.embed(text, params); | ||
| recordTokenMetrics(currentConnection.consumeTokenUsage()); |
There was a problem hiding this comment.
There's no try/finally around embed() and the consume, so if embed() throws after the connection already recorded usage, consumeTokenUsage() is skipped and the value persists on the thread. Bedrock hits this concretely: BedrockEmbeddingModelConnection records at line 139 before result.get("embedding") can return null and NPE at line 142, so a failed request leaves {tokens, tokens} in the ThreadLocal, and the next successful embed() on that thread folds those tokens in via the accumulate branch — inflating the metric with a plausible-looking number. The List overload at line 132 has the same shape, and embedding_model.py mirrors it. Would wrapping the consume in finally on both overloads (both languages) close the gap? The result-plus-usage change in the top-level comment would remove the need entirely, so worth deciding that direction first.
There was a problem hiding this comment.
Resolved via the result-plus-usage path rather than try/finally. BaseEmbeddingModelSetup records token metrics only after embedWithUsage(...) returns a successful result, so a provider exception has no side-channel slot to leak into the next call. Added Java and Python no-leak regression tests.
| long prompt = promptTokens == null ? 0L : promptTokens; | ||
| long total = totalTokens == null ? prompt : totalTokens; | ||
| EmbeddingTokenUsage current = lastTokenUsage.get(); | ||
| if (current != null) { |
There was a problem hiding this comment.
Because consumeTokenUsage() clears the slot after every embed(), in the happy path current is always null and this branch never runs — it only fires when a prior consume was skipped (the leak conditions above), and when it does it adds to the stale value rather than resetting, turning a dropped metric into a wrong one that's harder to spot. If the side channel stays, is overwrite (set, not +=) the safer default here than accumulate? I looked for a real multiple-record-per-consume case that would motivate the += and couldn't find one — Bedrock's size <= 1 loop runs at most once — but if there is one I've missed, it'd be worth a comment here since the branch reads as intentional.
There was a problem hiding this comment.
Removed with the side channel. There is no per-thread accumulate branch now; provider usage is returned with the embedding result, and metric accumulation only happens in the Flink counters after setup receives that result.
| } | ||
|
|
||
| @Test | ||
| void testEmbeddingTokenMetricsAccumulateAcrossRequests() { |
There was a problem hiding this comment.
The mocks here are honest, so this isn't about the existing assertions — it's the failure modes that aren't reached. Despite its name, testEmbeddingTokenMetricsAccumulateAcrossRequests makes two separate embed() calls with a consume clearing between them, so it proves the Flink Counter is monotonic but never enters the if (current != null) branch in BaseEmbeddingModelConnection. The paths most likely to regress are untested: a record-then-throw followed by a successful embed() (asserting the second call's metric is not inflated), and the Bedrock batch path. Are those two cases worth adding? They're the ones that would catch the correctness issues above if the side channel stays.
There was a problem hiding this comment.
Added the two regression cases called out here: prior provider failure followed by success does not inflate metrics in Java/Python setup tests, and Bedrock batch size > 1 aggregates usage from worker results. The existing cross-request test now only proves Flink counter monotonicity.
214994a to
e023346
Compare
|
Updated in e023346. Design change:
Regression coverage added/updated:
Local verification:
|
|
Hi @weiqingy, just a gentle follow-up when you have a chance. I updated this PR in
The PR is currently clean and all checks are green. If you still see any correctness/API concerns, I am happy to iterate; otherwise I would appreciate another review when convenient. Thanks again for the detailed review. |
|
Thanks @weiqingy for the detailed review. One additional concern: this PR records embedding token metrics inside If the same setup resource is invoked concurrently, setup-level metric recording can race during metric lookup/creation. Should embedding token recording stay at a caller/action-thread boundary where the single-threaded access guarantee is explicit? |
|
Updated in cf989b4 to address the request-scoped metric-group concern. What changed:
Local verification:
|
8477390 to
cf989b4
Compare
|
@zxs1633079383 Thanks for the update. Explicitly passing the request-scoped metric group fixes the mutable setup-bound metric group issue, but I think the thread-safety concern still remains. The current path still records metrics from the caller thread. For example, vector-store auto-embedding records inside Without changing the broader resource abstraction, one possible direction is to pre-resolve the embedding metric handles at the existing action-thread binding point, e.g. from Do you have any thoughts on where this should fit in the current abstraction? |
|
Thanks @joeyutong, I agree with the concern.
Before I make another implementation change, I think the right next step is to settle the abstraction boundary. Two possible shapes I am considering:
Either way, I think the recorder should avoid updating a non-thread-safe This also seems related to the broader direction discussed in #861: the cached |
|
Thanks for laying out the options. After thinking more about this, I do not have a fully comprehensive solution within the current resource abstraction either. I agree that a recorder-style direction could be the right long-term shape, but I do not think it should be embedding-specific. This seems more like a framework-level metric/effect abstraction: resource APIs may run from arbitrary caller threads, while actual The second option is more focused, but if the record path can still be called concurrently, it would either still race on the default Given that, my suggestion is to narrow this PR to provider usage extraction and usage-returning APIs only, without promising automatic metric recording from ordinary resource APIs such as vector-store auto-embedding. We can track the framework-level metric recording abstraction separately and solve automatic recording there. @weiqingy @zxs1633079383 , what do you think? |
weiqingy
left a comment
There was a problem hiding this comment.
@joeyutong Agreed with narrowing.
One thing worth pulling on before the recorder shape settles: I'm not sure ThreadSafeSimpleCounter would actually close the race. Flink's AbstractMetricGroup.addMetric / addGroup are already synchronized (this) — the unsynchronized lazy maps look like ours, FlinkAgentsMetricGroupImpl.java:42-50 (plain HashMaps, check-then-put in getSubGroup / getCounter). If that reading is right, a thread-safe counter would fix inc() and leave the lookup racing, so pre-resolving the handles at binding time may be doing more of the work than the counter swap. It's also @Internal in flink-metrics-core. Curious whether you see it the same way.
@zxs1633079383 Also, cf989b4 is currently red — 6 checks (5× it-python, 1× cross-language). The "all checks green" note was about the earlier e023346. Both failures trace to two lines in the Python vector store; left inline.
|
|
||
| def _ensure_embeddings(self, documents: List[Document]) -> None: | ||
| """Auto-embed any documents whose ``embedding`` field is ``None``.""" | ||
| embedding_model = self._get_embedding_model() |
There was a problem hiding this comment.
This now calls self._get_embedding_model() unconditionally before the loop, only so request_metric_group = self.metric_group can be captured once. On origin/main the resolution was lazy, inside the branch that actually needs it:
for doc in documents:
if doc.embedding is None:
doc.embedding = self._get_embedding_model().embed(doc.content)The effect is that a store which never auto-embeds — mem0 embeds server-side — now raises on add/get/delete. That's the 13 failures in test_mem0_vector_store.py: TypeError: No embedding model configured on this vector store. Could the resolution move back inside the if doc.embedding is None branch, and the metric group be read only on the path that embeds?
| for doc in documents: | ||
| if doc.embedding is None: | ||
| doc.embedding = self._get_embedding_model().embed(doc.content) | ||
| result = embedding_model.embed_with_usage(doc.content) |
There was a problem hiding this comment.
The call moved from embed(...) to embed_with_usage(...), and that quietly changes the extension point. BaseEmbeddingModelSetup.embed() delegates to embed_with_usage(), but embed_with_usage (embedding_model.py:157) goes straight to self._get_connection() — so a subclass that overrides embed() is no longer on the vector-store path. That's the 7 test_chroma_vector_store.py failures: TypeError: Expect BaseEmbeddingModelConnection, but is str, because MockEmbeddingModel.embed() is bypassed and the mock's connection is the string 'mock'. Beyond the test mock, this is a public-API behavior change — is embed() still meant to be an override point? If so, having the vector store go through it (with usage surfaced some other way) would keep that contract intact.
|
I agree that pre-resolving the metric handles at binding time addresses the lazy-map race, as I mentioned above. My remaining concern is that metric recording would still update the pre-resolved counters from arbitrary caller threads. Although this avoids concurrent I am not sure whether that is consistent with the intended single-threaded access contract of the current metric abstraction, so I would prefer not to pursue this direction in this PR for now. This is also why I suggested narrowing the PR to usage extraction and usage-returning APIs. |
# 影响范围 - Java/Python `BaseEmbeddingModelConnection` 与 `BaseEmbeddingModelSetup` 保留 `embedWithUsage`/`embed_with_usage` 的结果携带链路;普通 `embed` API 的返回类型不变。 - `BaseVectorStore.query`、自动补 embedding 以及 Python 对应实现恢复仅调用 `embed`,不再进入指标组或计数器。 - OpenAI、Tongyi、Bedrock 的 provider usage 提取及批量聚合能力保持不变;测试改为断言 usage 结果本身。 # 改动影响面 - 主链路:embedding provider -> `EmbeddingResult` -> 调用方显式消费 usage;本提交不再把 usage 写入 `MetricGroup`。 - 向量库扩展点:恢复 `BaseEmbeddingModelSetup.embed` 覆盖语义,并且仅在确有缺失 embedding 时延迟解析模型,避免 Mem0 无模型路径报错。 - 不受影响范围:apache#860/apache#861 的 resource metric-group 传播和 action-scoped metric 语义未改动;未引入新的 metric abstraction。 # 功能改进/开发/新增 - 类型: refactor - 触发条件: 普通资源 API 可并发调用,无法安全保证 `MetricGroup`/`SimpleCounter` 的线程模型;Python vector-store 还绕过了 `embed` 扩展点。 - 根因类别: 指标写入边界不明确、公共 API 扩展点回归、惰性资源解析边界遗漏。 - 行为变化: 有;PR 仅暴露 provider usage 返回 API,不再自动记录 embedding token metrics。 # 验证 - `mvn --batch-mode --no-transfer-progress -pl api -Dtest=BaseEmbeddingModelSetupEmbeddingResultTest test` PASS - `mvn --batch-mode --no-transfer-progress -pl integrations/embedding-models/bedrock -am -Dtest=BedrockEmbeddingModelTest -Dsurefire.failIfNoSpecifiedTests=false test` PASS - `uv run --python 3.12 --extra test pytest ...vector_stores... -q` PASS (49 passed, 4 skipped) - `./tools/lint.sh --check` PASS - `uv run --python 3.12 --extra lint ruff format --check ... && uv run --python 3.12 --extra lint ruff check ...` PASS - `git diff --check` PASS
|
@joeyutong @weiqingy Thanks, I agree with narrowing this PR. I pushed 5af8560, which removes automatic embedding token metric recording and leaves this PR focused on provider usage extraction plus the The vector-store paths are back to the existing I also agree that pre-resolving handles would only address the lazy lookup race, not the broader question of mutating counters from arbitrary resource threads. I will not pursue a Local verification for the affected paths is green: 49 Python tests (including Chroma and Mem0 vector stores), the API embedding-result test, and the Bedrock batch-usage test. CI should now rerun on the narrowed patch. Thanks again for steering this toward a cleaner boundary. |
Linked issue: #858
Purpose of change
This adds provider-neutral token usage metrics for embedding model calls.
Embedding providers can now report input-side usage through the embedding
connection, while embedding setup records the usage under the current model
metric group after the request completes. Providers without token usage remain
no-op.
Covered paths in this PR:
inputTextTokenCountextraction when present.Tests
cd python && uv run --python 3.12 --extra test pytest flink_agents/api/embedding_models/tests/test_token_metrics.py flink_agents/integrations/embedding_models/tests/test_openai_embedding_model.py flink_agents/integrations/embedding_models/tests/test_tongyi_embedding_model.py -qcd python && uv run --python 3.12 --extra lint ruff format --check flink_agents/api/embedding_models/embedding_model.py flink_agents/api/embedding_models/tests/test_token_metrics.py flink_agents/integrations/embedding_models/openai_embedding_model.py flink_agents/integrations/embedding_models/tests/test_openai_embedding_model.py flink_agents/integrations/embedding_models/tongyi_embedding_model.py flink_agents/integrations/embedding_models/tests/test_tongyi_embedding_model.py && uv run --python 3.12 --extra lint ruff check flink_agents/api/embedding_models/embedding_model.py flink_agents/api/embedding_models/tests/test_token_metrics.py flink_agents/integrations/embedding_models/openai_embedding_model.py flink_agents/integrations/embedding_models/tests/test_openai_embedding_model.py flink_agents/integrations/embedding_models/tongyi_embedding_model.py flink_agents/integrations/embedding_models/tests/test_tongyi_embedding_model.pyexport JAVA_HOME=/usr/local/opt/openjdk@17/libexec/openjdk.jdk/Contents/Home; export PATH="$JAVA_HOME/bin:$PATH"; mvn --batch-mode --no-transfer-progress -pl api -Dtest=BaseEmbeddingModelSetupTokenMetricsTest testexport JAVA_HOME=/usr/local/opt/openjdk@17/libexec/openjdk.jdk/Contents/Home; export PATH="$JAVA_HOME/bin:$PATH"; mvn --batch-mode --no-transfer-progress -pl api,integrations/embedding-models/bedrock -am spotless:check -Dspotless.skip=falseexport JAVA_HOME=/usr/local/opt/openjdk@17/libexec/openjdk.jdk/Contents/Home; export PATH="$JAVA_HOME/bin:$PATH"; mvn --batch-mode --no-transfer-progress -pl integrations/embedding-models/bedrock -am -DskipTests compilegit diff --checkAPI
This adds
EmbeddingTokenUsageand protected token-usage recording helpers forembedding model connections. Existing
embed(...)return types are unchanged.Documentation
doc-neededdoc-not-neededdoc-included