[api][python][java] Track embedding token usage metrics#870
[api][python][java] Track embedding token usage metrics#870zxs1633079383 wants to merge 6 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. |
|
@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. |
# 影响范围 - 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
# 影响范围 - 直接修改 api/src/main/java/org/apache/flink/agents/api/embedding/model/BaseEmbeddingModelSetup.java。 - 影响 BaseEmbeddingModelSetup.embedWithUsage(List<String>, Map<String,Object>) 末尾格式区域;不改变方法签名、返回值或调用链。 # 改动影响面 - 主链路:embedding setup -> BaseEmbeddingModelConnection.embedWithUsage(...) 保持不变,仅移除 Spotless 标记的类尾多余空行。 - 数据/协议变化:无字段、API、指标、事件或响应结构变化。 - 不受影响范围:EmbeddingResult、provider usage 提取、vector-store embed 调用路径、apache#860/apache#861 metric group 传播语义均不变。 # 功能改进/开发/新增 - 类型: fix - 触发条件: PR apache#870 的 Code Style Check 在 BaseEmbeddingModelSetup.java 上报 Spotless format violation。 - 根因类别: 格式边界遗漏。 - 行为变化: 无;仅修复 CI 格式检查。 # 验证 - ./tools/lint.sh -c PASS - mvn --batch-mode --no-transfer-progress -pl api spotless:check PASS(当前配置显示 Spotless check skipped,但 Maven 目标成功)
5af8560 to
2fa0ae0
Compare
|
Updated in 2fa0ae0. What changed:
Local verification:
The new CI run is in progress; Code Style Check is already green. |
| } | ||
|
|
||
| public float[] embed(String text, Map<String, Object> parameters) { | ||
| return embedWithUsage(text, parameters).getEmbeddings(); |
There was a problem hiding this comment.
Could we preserve the existing embed(...) dispatch here and call getConnection().embed(...) directly? With the current delegation, a subclass of a usage-aware provider connection that overrides the existing embed(...) method is bypassed, because providers such as OpenAI, Tongyi, and Bedrock override embedWithUsage(...) and perform the request there. The Python setup has the same pattern. Since plain embed(...) does not consume usage, mapping the two setup APIs directly to the corresponding connection APIs would preserve the existing extension point.
There was a problem hiding this comment.
Updated in 0dc947f.
BaseEmbeddingModelSetup.embed(...) now resolves the setup parameters and calls getConnection().embed(...) directly. embedWithUsage(...) remains on the separate usage-aware path. The Python BaseEmbeddingModelSetup.embed(...) now follows the same dispatch rule.
I added Java and Python regressions where embed(...) and embedWithUsage(...) intentionally return different embeddings, proving that the ordinary API preserves the existing provider/subclass override point.
| """ | ||
| return self.embed_with_usage(text, **kwargs).embeddings | ||
|
|
||
| def embed_with_usage( |
There was a problem hiding this comment.
The new setup-level usage API also needs explicit cross-language bridges. PythonEmbeddingModelSetup inherits the Java embedWithUsage(...) implementation even though its open() delegates to the Python setup and never initializes the base Java connection; conversely, JavaEmbeddingModelSetupImpl inherits this Python method while its connection remains a resource name. Those calls therefore fail instead of crossing the language boundary. The connection wrappers currently fall back to legacy embed(...) and return no usage as well. Could we bridge the methods and result conversion in both setup and connection wrappers, and cover them in the cross-language tests?
There was a problem hiding this comment.
Updated in 0dc947f.
I added explicit bridges for embedWithUsage(...) / embed_with_usage(...) on both setup and connection wrappers.
For Java -> Python, the bridge calls python_java_utils.call_embedding_with_usage, which converts the Python EmbeddingResult dataclass into a Pemja-safe map containing only embeddings, token-usage maps, and primitives before Java reconstructs EmbeddingResult. This avoids relying on Pemja to expose dataclass attributes. For Python -> Java, both Java resource wrappers now call Java embedWithUsage(...) directly and convert its result back to the Python dataclass.
Coverage now includes Java setup/connection wrapper conversions, Python Java-wrapper conversions, and the existing Java/Python resource-cross-language agents exercise the single and batch usage APIs.
| token_usage = None | ||
| if prompt_tokens is not None or total_tokens is not None: | ||
| token_usage = EmbeddingTokenUsage( | ||
| prompt_tokens=int(prompt_tokens or 0), |
There was a problem hiding this comment.
Could we handle the native DashScope response shape here? dashscope.TextEmbedding.call() documents top-level usage.total_tokens as the number of input tokens, without an input_tokens or prompt_tokens field. The current code therefore produces prompt_tokens=0 for the documented response. Since that total_tokens value already represents the embedding input count, could we use it as the prompt-token fallback and test the actual shape with top-level usage={"total_tokens": 6}?
There was a problem hiding this comment.
Updated in 0dc947f.
Tongyi now falls back from usage.total_tokens to prompt_tokens when neither input_tokens nor prompt_tokens is present. This matches the documented DashScope top-level response shape, where total_tokens is the embedding input-token count.
The regression test now uses the native shape: top-level usage={"total_tokens": 6} and verifies both prompt and total tokens are reported as 6.
# 影响范围 - 入口:Java/Python embedding setup 与 connection 的 embed/embedWithUsage 双 API。 - 调用链:setup -> connection -> provider,以及 Java<->Python resource wrapper 和 resource-cross-language E2E agent。 - 字段/协议:EmbeddingResult.embeddings、token usage 的 prompt_tokens/total_tokens;Tongyi 支持原生 usage.total_tokens 响应。 # 改动影响面 - 普通 embed(...) 恢复直调同名 connection API,保留既有 provider/subclass override 分派;usage API 保持独立路径。 - Java 调 Python 将 dataclass 显式平铺为 Pemja 安全的 list/map/primitive;Python 调 Java 显式调用 embedWithUsage(...) 并还原结果。 - 不受影响范围:现有 embed(...) 返回类型、无 usage provider 的 no-op 语义、向量存储的 legacy embed 调用均不变。 # 功能改进/开发/新增 - 新增双向 wrapper、单/批 usage 结果转换与 Java/Python 回归测试。 - 现有跨语言 embedding agent 改为覆盖 usage API;Tongyi total_tokens 作为 prompt_tokens 回退。 # 验证 - JDK17 Maven API 定向测试:29 tests PASS。 - Python 3.12 定向 pytest:13 passed, 2 skipped。 - Ruff format/check、Spotless check、resource-cross-language test-compile、git diff --check PASS。 - Ollama 不可用,外部服务 E2E 未在本机执行。
|
@joeyutong The three latest inline comments are addressed in 0dc947f, with replies on each thread. Targeted Java/Python checks are green locally, and the new CI run is in progress. Could you please re-review when convenient? |
|
Thanks for taking this on, @zxs1633079383, and thanks to the reviewers, @joeyutong and @weiqingy. Currently, there are some conflicts with main. I’ll merge this once they are resolved. |
# 影响范围 Java/Python embedding 跨语言适配层;同步 origin/main 中 apache#860 的 metric-group 传播能力。 # 改动影响面 入口为 PythonEmbeddingModelConnection 与 PythonEmbeddingModelSetup。调用链同时保留普通 embed 扩展点、embedWithUsage 用量结果桥接、PythonResourceWrapper metric-group 绑定;未改动 runtime -> plan -> api 依赖方向,也未恢复自动 token metric 记录。 # 功能改进/开发/新增 合并 origin/main,并在两处 import 冲突中同时保留 EmbeddingResult 与 FlinkAgentsMetricGroup。自动合并的 Python runtime wrapper 同时保留 usage 结果转换和 metric-group forwarding。未纳入本地 .codex/。 # 验证 Java API 29 tests;Bedrock 5 tests;runtime adapter 9 tests;Python embedding/wrapper 6 tests;Python bridge/metric 11 tests;provider 6 passed/3 skipped;cross-language test-compile;Ruff、Spotless(Java 17)、git diff --check 通过。真实 Ollama cross-language E2E 未执行。
|
Resolved the current The two conflicting Java/Python embedding wrappers now preserve both sides of the contract: the Local verification is green: 43 focused Java tests, 23 focused Python tests (3 provider-dependent skips), Java 17 Spotless, Ruff, runtime adapter coverage, and cross-language test compilation. The new CI run is queued, and GitHub now reports the PR as mergeable. |
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