I ended the last DSH piece with a promise:
Next I want to wire DSH up to the local TencentDB Agent Memory as a callable tool and get a real end-to-end scenario running. I'll write it up once it works.
It works now. Time to pay that debt.
It was not a smooth plug-in. It was five problems stacked on top of each other: a wrong model id, a network address that didn't exist, a proxy injecting an unreachable gateway, a model hallucinating an empty tool name, and a "knowledge base resource does not exist" ghost story. Every one of them taught me something about a different layer of the stack. Below is the order I actually debugged them in, not a tidy textbook order.
What I was trying to build
Let me set the target first so the rest makes sense.
I run DSH (DeepSeek Harness) from source on Windows. I wanted it to call a set of external memory tools during a conversation — searching past sessions, writing knowledge, querying a code graph. That memory system is TencentDB Agent Memory, running in Docker under WSL2, split across three containers:
-
tdai-proxy— model proxy plus the skill/memory injection gateway, port 8096 -
tdai-memory-hub— admin panel, 8125 / 8424 -
tdai-memory-core— identity, assets, memory storage, 8420
The integration mode is proxy mode: dsh doesn't talk to OpenAI or DeepSeek directly. It sends LLM requests to http://127.0.0.1:8096/dsh/default. Before and after forwarding upstream to a relay, the proxy injects <skill_tools> and <tdai_memory_tools> blocks into the system prompt so the model knows these tools exist; the actual calls then go back to the proxy's bridge endpoint.
Sounds like a one-line change to the API base URL, right? That's exactly what I thought, too.
Trap 1: the SSE stream just died
Right after changing the address, every turn failed with SSE stream ended without [DONE], error code STREAM_CLOSED.
My first instinct was that the proxy had crashed, but the container logs said it was healthy. So I hit the relay directly with curl and got this back:
{"code":"model_not_found","message":"No available channel for model deepseek-v4-pro under group 百炼国模"}
Alibaba's Bailian platform doesn't expose bare model aliases. Every snapshot is an independent id with a date suffix, something like deepseek-v4-pro-0813. The relay had no channel configured for deepseek-v4-pro, so it returned 503. dsh's SSE parser never received [DONE] before EOF and treated it as a truncated stream — that's by design, not a bug.
Editing ~/.dsh/settings.yaml fixed it: I swapped the id declared under llm-deepseek.models for deepseek-v4-pro-0813, pointed agent-default-model.model at the same thing, and the first turn went through.
That was only the first domino.
Trap 2: not enough balance, reported as "invalid API key"
With the model id correct, longer sessions started failing again. The dsh UI said "this turn failed: invalid API key", error code AUTH.
That wording is dangerously misleading. I nearly went off to rotate my key. The proxy logs told a different story — upstream had returned 403:
{"message":"预扣费额度失败, 用户剩余额度: $56.639376, 需要预扣费额度: $59.112000","code":"insufficient_user_quota"}
new-api freezes the worst case up front: (input tokens + output cap) × model multiplier × group multiplier. On this relay that works out to roughly $0.46 per 1K tokens of context, so a single 128K-context turn wanted close to $60 pre-authorized. Not enough balance, hence 403. dsh maps both 401 and 403 onto AUTH, which is how a quota problem turned into "invalid API key".
Worth noting: this isn't the real charge. After the request completes it settles against actual usage — a 128K turn really costs about $2.3 — but you still have to clear the pre-auth threshold first.
My stopgap was to keep context under 100K, which dropped the pre-auth below the threshold and let sessions continue. Longer term it's either top up, switch to a lower-multiplier channel, or actively manage session length.
The lesson I took from this: dsh's error text is only a local classification. For the precise cause, always read the proxy log or the raw upstream response.
Trap 3: the proxy injected an address Windows can't reach
With the LLM path working, I moved on to testing skill calls. The bridge address written into the <skill_tools> block was http://172.18.0.4:8096, and calls timed out immediately.
From the Windows side, curl 127.0.0.1:8096/skill/search returned 401 — a business response, meaning the service was alive — but curl 172.18.0.4:8096 gave me a flat curl 52 Empty reply from server.
This is the classic WSL2 half-open situation: the Docker bridge subnet 172.18.0.0/16 has no working return route to Windows. The TCP handshake appears to succeed, but the response never comes back. A container IP shouldn't be referenced from Windows in the first place, and it drifts on every restart anyway.
So the real question became: who writes that address into the injected block?
Reading the proxy source, the base URL decision in MemoryProxy/src/injection/index.ts is:
- if
injection.externalGatewayUrlis configured → use it - otherwise → fall back to the first non-internal IPv4 from
os.networkInterfaces(), which inside a container is the Docker bridge IP
That fallback was designed for single-machine local development. Under Windows + WSL2 it picks an address that is unreachable from the side dsh runs on.
The worse part: start-proxy.sh unconditionally regenerates .proxy-config/config.yaml on every start, so hand-editing the output is pointless. The fix has to go into the generation template:
- Add a line to the
injection:section of thestart-proxy.shtemplate:externalGatewayUrl: "${PROXY_EXTERNAL_GATEWAY_URL:-}" - Append to
.env:PROXY_EXTERNAL_GATEWAY_URL=http://127.0.0.1:8096 - Re-run the startup script and verify the generated file
This one re-taught me something I keep re-learning: when a config file is generated by a script, the fix belongs in the generation chain. Hand-editing generated output is planting a landmine.
Trap 4: the model called a nameless tool, and the parser believed OpenAI's format
Once the bridge address was 127.0.0.1:8096, skill calls went out — and dsh immediately reported unknown tool "". The tool name was empty.
This was the most instructive failure of the five. I bypassed dsh entirely, curl'd the proxy with the same headers, and looked at the raw SSE bytes coming back upstream:
chunk 1: tool_calls[0].function.name = "get_weather", arguments = ""
chunk 2: tool_calls[0].function.name = "", arguments = '{"'
chunk 3: tool_calls[0].function.name = "", arguments = 'city":"'
OpenAI's streaming convention puts the tool name only in the first chunk; continuation chunks leave the name field present but empty as a placeholder. dsh's parser in packages/llm/llm-deepseek/src/translate.ts, however, was written as:
if (call.function?.name !== undefined) block.name = call.function.name
An empty string is not undefined, so the correctly accumulated name got overwritten with "".
The official DeepSeek API simply omits the name field on continuation chunks, which is why this loose check never caused trouble before. But my upstream is an OpenAI-format relay, where the field exists and is empty — so it triggered instantly.
The fix is one line:
if (call.function?.name) block.name = call.function.name
I added a unit test alongside it, pinning the behavior with the exact data shape I captured.
This reinforces what I said last time: for streaming or protocol mismatches across services, always capture the raw bytes before you read the code. There's a proxy, an adapter, and a translation layer in between, and the logs at any one of them are already somebody's interpretation.
Trap 5: the graph is ready, but allocation says "resource does not exist"
The last one felt like a haunting. Memory Hub's Code_Graph page showed both repositories as Ready, but clicking "Assign to Agent" failed with 知识库资源不存在或已被删除 ("knowledge base resource does not exist or has been deleted") and a request_id.
I traced the call chain by request_id through the tdai-memory-hub and tdai-memory-core logs:
POST /api/v1/knowledge/allocate
→ /v3/meta/auth/verify 200
→ /v3/meta/team-member/get 200
→ /v3/meta/asset/get cg-x7dg44vu → 404 asset_not_found
So the code graph itself lives in the knowledge service's own storage, and "Ready" on the page only means the graph was built successfully. But "Assign to Agent" validates against memory-core's asset registry, meta_asset. By design, once the graph finishes building, KS should call back into Panel, which then back-fills the asset registration — and that callback is best-effort. It doesn't block the build when it fails, which leaves you in a middle state where the graph exists but the registration doesn't.
Panel ships an idempotent backfill endpoint for exactly this. One call:
curl -X POST http://127.0.0.1:8125/api/v1/knowledge/code-graph/register-meta \
-H "Content-Type: application/json" \
-H "x-tdai-service-id: default" \
-H "x-tdai-user-key: <sk-mem-...>" \
-d '{"team_id":"team-a5bu20zsn8","code_graph_id":"cg-x7dg44vu"}'
Once per graph, returning registered: true, and allocation started working immediately.
The general lesson: when a component's "displayed state" and its "authoritative state" live in two different stores, a healthy UI doesn't mean validation will pass. Best-effort async registration needs an explicit compensation entry point.
What it feels like now
With all five resolved, dsh can finally call the memory tools mid-conversation. Not the cheap thrill of "changed a config and it worked" — more like the solid feeling you get when every layer's root cause lines up with the symptom above it.
The single move I relied on most across this whole debugging session: split the error into layers, then go read raw data one layer down. dsh's UI text, the proxy's log, the upstream response, the source code's conditionals — where those three agree, there's no bug; where they disagree, that's your bug.
I already wrote about DSH's "if the model can see it, it's recorded" principle and Cordis's plugin composition in the previous article. Wiring in a real external memory system made the value concrete for me: plugin architecture isn't just about swapping models, it's about plugging an entire memory system in as another Provider. And traceability isn't just logging — it's being able to peel the onion layer by layer instead of circling in front of a UI error message.
What's next? Two directions on the desk. One is walking DSH's plugin loading and development flow myself — last time I said everything is a plugin, and now I want to move from being a plugin consumer to a plugin developer, using a real requirement as the exercise. The other is pushing the Agent Memory integration deeper, so retrieval and writes become routine moves in a conversation rather than a single demo path. Whichever produces results first gets written up first.
原文发表于 沐沐ai专题
关注「沐沐ai专题」公众号,获取更多 AI 实战干货












