A while ago, I deployed Memoh as a few Telegram group-chat bots, mainly to burn through an Ollama subscription I couldn't possibly use up on my own (some idiot subscribed to a full year of Ollama at once).

After running it for a bit, I noticed that ten minutes of group chat could burn through half of my 5-hour limit, and a single day would eat half of my weekly quota. That got me thinking: in a pure chat scenario, how can I drive down both the number of LLM calls and the token consumption per call? I made a set of targeted changes for group chat, and my total cost dropped to less than a fifth of what it used to be.
To be clear, this post isn't here to nitpick upstream. Memoh is a thoughtfully designed general-purpose agent framework—multi-step tool loops, a rich toolset, and structured message metadata are all reasonable and even necessary for an agent that "gets work done for you." What I did was specialize it into a chatbot: an agent in a group chat spends most of its life just talking rather than executing tasks, and "talking" inside a general-purpose agent loop has a very unfavorable cost structure.
Where Does the Money Actually Go?
First, here is the breakdown of a real request captured before my optimizations:
| Component | Size | Share |
|---|---|---|
| Messages (conversation history) | ~157.7 KB | 68.0% |
| Tools (tool schemas) | ~49.5 KB | 21.3% |
| System (system prompt) | ~24.7 KB | 10.7% |
A single request is over 200 KB—roughly 50,000 tokens. The catch is what a general-purpose agent's tool loop does with that payload: every extra tool call re-reads all 50,000 tokens from scratch. And chat scenarios are packed with tool calls: sending a message is one, replying is one, sending a sticker is another, and after sending, a delivery status comes back for the model to read and confirm. Behind a single outgoing group-chat message, the full context is read 2 to 3 times over.
So my changes followed three directions: make fewer calls, read less per call, and hit the prompt cache on almost everything already read. Every change below includes the actual code from my fork (mizorewww/Memoh, compared against upstream baseline d0c5c735^).
Change 1: Clock Out Right After Sending
Upstream's loop works like this: the model decides to send a message → calls send → the framework returns the delivery status (message ID, platform, target conversation, etc.) as a tool result → the model is invoked a second time, reads the entire context again, says "okay, sent," and finishes. For a group-chat bot, that final call is almost pure waste: the message has already been delivered to the chat, and there is nothing meaningful left for the model to do except echo a confirmation. With the Doubao model, an even worse failure mode showed up: seeing the success result, the model kept calling send over and over in an infinite loop.
My fix wraps the provider in a decorator (internal/agent/runtime/native/terminal_send.go). Once send successfully delivers to the current conversation, the next time the model would be called, the decorator immediately returns FinishReasonStop locally without making a network request at all:
76type terminalSendProvider struct {
77 sdk.Provider
78 state *terminalSendState
79}
80
81func (p *terminalSendProvider) DoGenerate(ctx context.Context, params sdk.GenerateParams) (*sdk.GenerateResult, error) {
82 if p.state.isDelivered() {
83 return &sdk.GenerateResult{
84 FinishReason: sdk.FinishReasonStop,
85 Response: sdk.ResponseMetadata{ID: terminalSendResponseID},
86 }, nil
87 }
88 return p.Provider.DoGenerate(ctx, params)
89}
90
91func (p *terminalSendProvider) DoStream(ctx context.Context, params sdk.GenerateParams) (*sdk.StreamResult, error) {
92 if !p.state.isDelivered() {
93 return p.Provider.DoStream(ctx, params)
94 }
95 parts := make(chan sdk.StreamPart, 2)
96 parts <- &sdk.FinishStepPart{
97 FinishReason: sdk.FinishReasonStop,
98 Response: sdk.ResponseMetadata{ID: terminalSendResponseID},
99 }
100 parts <- &sdk.FinishPart{FinishReason: sdk.FinishReasonStop}
101 close(parts)
102 return &sdk.StreamResult{Stream: parts}, nil
103}Of course, the protocol requires every tool_call to be paired with a tool_result, so we can't literally drop the result without leaving a dangling tool call in history. Instead, the result is compressed to the bare minimum: a successful send to the current conversation returns just {"ok": true}, only attaching target metadata when sending across conversations; failures return a structured retryable error (error_code + retryable + guidance) so the model has a chance to recover instead of crashing the loop with a Go error:
311func (p *MessageProvider) execSend(ctx context.Context, session SessionContext, toolCallID string, args map[string]any) (any, error) {
312 if session.SessionType == sessionmode.Discuss {
313 sendResult, err := p.exec.SendDirectForTool(ctx, toMessagingSession(session), "", toolCallID, args)
314 if err != nil {
315 return messageSendErrorResult(err)
316 }
317 resp := map[string]any{"ok": true}
318 if !session.IsSameConversation(sendResult.Platform, sendResult.Target) {
319 resp["platform"] = sendResult.Platform
320 resp["target"] = sendResult.Target
321 resp["delivered"] = "target"
322 }
323 return resp, nil
324 }There is one more layer in history projection. When future turns read the conversation history, successful send calls are folded into a minimal call/result pair containing only the five audience-visible fields (text, reply_to, sticker_id, attachments, message), stripping delivery internals (bot_id, platform, message_id, etc.) from the prompt context. Note that this folding happens only in the projection layer—the persisted canonical history remains untouched for debugging:
232func minimalDiscussSendCall(call sdk.ToolCallPart) sdk.ToolCallPart {
233 input := normalizeToolInputMap(call.Input)
234 minimal := make(map[string]any, 5)
235 // Current-conversation routing is implicit. Keep only audience-visible
236 // payload fields and the reply reference needed to understand the exchange.
237 for _, key := range []string{"text", "reply_to", "sticker_id", "attachments", "message"} {
238 if value, ok := input[key]; ok {
239 minimal[key] = value
240 }
241 }
242 return sdk.ToolCallPart{
243 ToolCallID: strings.TrimSpace(call.ToolCallID),
244 ToolName: "send",
245 Input: minimal,
246 }The result: on pure messaging turns, LLM calls drop from 2 to 1—an immediate 50% savings. Multi-step workflows that search the web or call other tools still work normally; the bot simply treats send as the terminal action of the turn.
Change 2: Send Text, Reply, and Sticker in a Single Call
In group chats, a single response is often both a reply to someone and accompanied by a sticker. Previously, that required multiple tool calls, re-reading the entire context each time. I merged them into a single send tool signature:
send(text[optional], reply_to[optional], sticker_id[optional])Concretely, this involved two changes. First, upstream already had a top-level reply_to parameter alongside a redundant nested message.reply{message_id} object; I removed the nested form, kept only the top-level parameter, and restricted it to message IDs visible in the current turn (returning a retryable error if invisible instead of replying to the wrong message).
Second, I had previously written an MCP server for sending stickers, only to realize that invoking an external MCP tool added an extra LLM round-trip every time. I folded the sticker catalog directly into the sticker_id parameter of send:
14 "github.com/memohai/memoh/internal/delivery"
15)
16
17const (
18 telegramStickerSendGuidance = " For Telegram, normally pair every coherent public message with at least one context-appropriate Sticker by setting `sticker_id` in the same send call; omit it only when the catalog has no suitable Sticker. Choose from the full visual descriptions, not from the original emoji alone, and avoid repetitive duplicates."
19 telegramStickerCatalogSchemaKey = "x-memoh-sticker-catalog"
20)
21
22type telegramStickerSchemaEntry struct {
23 ID string `json:"id"`
24 Description string `json:"description"`
25 Emoji string `json:"emoji"`
26 Status string `json:"status"`
27}
28
29// mergeTelegramStickerSendTools converts the Sticker MCP into an internal
30// backend for the first-party send tool. The model sees one send call whose
31// sticker_id enum contains the complete, stable catalog; it never sees aWithin a single send call, the bot delivers the text first and then the sticker; if the sticker fails after the text is already sent, it returns partial success and terminates immediately so the model won't retry and send duplicate text.
I also had a vision model pre-generate a concise visual description (under 50 characters) for every sticker, stored alongside the sticker-set metadata in SQLite. Sorted deterministically by ID, these descriptions are embedded into the send tool schema's description. Once merged, sending a sticker incurs virtually zero extra LLM cost.
Change 3: Move Everything That Changes to the Tail of the Prefix
This is the key to prompt-cache hit rates. Every provider's prompt caching mechanism (Anthropic, OpenAI, DeepSeek, Kimi—all of them) works by prefix matching: the prefix of a request must match byte-for-byte, and the first byte that changes invalidates the cache for everything after it.
To be fair, upstream's default behavior isn't bad across ordinary turns: conversation history is appended at the end, so automatic prefix caches like DeepSeek's catch most of it. The real leak was a frequently rewritten file buried in the middle of the system prompt: upstream placed MEMORY.md between AGENTS.md and PROFILES.md. Because MEMORY.md is rewritten constantly by the memory subsystem, every update invalidated the cache for everything following it. My fix was a one-function change:
66// systemFileLoadOrder keeps the frequently changing memory summary at the
67// tail of the system prompt. Stable instruction/profile content therefore
68// remains in the reusable prefix when MEMORY.md changes between turns.
69func systemFileLoadOrder() []string {
70 return []string{"AGENTS.md", "PROFILES.md", "MEMORY.md"}
71}Changing the load order from AGENTS.md → MEMORY.md → PROFILES.md to AGENTS.md → PROFILES.md → MEMORY.md keeps the most stable instructions at the front of the prefix and pushes the most volatile file to the very end of the system prompt. Following the same principle, I cleaned up several other sources of "prefix pollution":
- The memory-writing manual (~2.6 KB) is now injected only when a memory provider is actually configured; when set to
None, the entire section is omitted:
710MemoryInstructions: strings.TrimSpace(botSettings.MemoryProviderID) != "",- Platform identity now injects only the active platform's fields, and strips
avatar_url(which contained the bot token) from the injected prompt.
The difference is measurable: under the old "dynamically mutate system prompt" path, a forced-reply call measured just 5.1% cache read (a single measurement of that specific code path, not the everyday baseline). After the fix, two consecutive requests in a live group chat sent 52,259 and 52,633 input tokens, of which 52,096 and 52,480 were served from cache—a 99.7% cache hit rate, leaving only ~160 uncached input tokens billed at full price per turn.
Change 4: Invisible Request Slimming
Smaller requests mean cheaper reads on every turn. This part stacks several small reductions together, all exposed as configurable Telegram channel policies stored in bot metadata:
21TelegramToolCallsEnabledMetadataKey = "telegram_tool_calls_enabled"
22TelegramEnabledToolsMetadataKey = "telegram_enabled_tools"
23TelegramSkillsEnabledMetadataKey = "telegram_skills_enabled"
24TelegramMessageMetadataModeKey = "telegram_message_metadata_mode"telegram_tool_calls_enabledis the master switch for tools,telegram_enabled_toolsis an explicit allowlist (an empty array disables all tools), andtelegram_skills_enabledcontrols skills. A chat bot doesn't need shell execution or file editing; on my two production bots I enabled onlysend, turning off 37 tools. The crucial detail is when filtering happens: the allowlist runs before schema generation, so disabled tools don't contribute a single byte to the request:
990}
991filtered := make([]sdk.Tool, 0, len(providerTools))
992for _, tool := range providerTools {
993 if cfg.ChannelPolicy.AllowsTool(tool.Name) || cfg.ChannelPolicy.AllowsBackendTool(tool.Name) {
994 filtered = append(filtered, tool)
995 }
996}
997providerTools = filteredBefore optimization, the Tools section accounted for 21.3% of the request (~49.5 KB, roughly 12,000 tokens); now it is almost completely gone. Policy evaluation is fail-closed: if reading the policy fails, it exposes no tools rather than leaking tools that should be disabled.
telegram_message_metadata_modecontrols how message metadata headers are projected, defaulting tocompacton Telegram. Upstream prepends a full metadata header to every message sent to the model:<message id="..." sender="..." t="..." channel="telegram" conversation="..." type="..." target="...">. The canonical format in SQLite stays unchanged, but the transient projection sent to the LLM strips the per-message repeated timestamp, platform, and conversation name while keeping speaker attribution for group chats:
165func ProjectUserMessageHeader(content, mode string) string {
166 if strings.ToLower(strings.TrimSpace(mode)) != "compact" || !strings.HasPrefix(strings.TrimSpace(content), "<message") {
167 return content
168 }
169 start := strings.Index(content, "<message")
170 if start < 0 {
171 return content
172 }
173 endRel := strings.Index(content[start:], ">")
174 if endRel < 0 {
175 return content
176 }
177 end := start + endRel
178 compactOpening, ok := projectMessageOpeningTag(content[start : end+1])
179 if !ok {
180 return content
181 }
182 return content[:start] + compactOpening + content[end+1:]
183}That saves 85–120 characters per message, reduces occurrences of channel="telegram" in a request from 148 down to 2, and shrinks a full-history payload from 221.0 KB to 208.7 KB.
Change 5: Giving Text-Only Models "Eyes"
Some of the primary models I run don't support vision (such as DeepSeek V4 Flash), yet people post images in group chats all day long. I added an auxiliary vision model mechanism: when the primary model declares no vision support and the current turn includes image attachments, a cheap small vision model describes the images first:
183func (s *Service) describeImagesWithAuxiliaryVision(
184 ctx context.Context,
185 req ChatRequest,
186 primarySupportsVision bool,
187 attachments []gatewayAttachment,
188) string {
189 if s == nil || primarySupportsVision {
190 return ""
191 }
192 images := extractNativeImageParts(attachmentsToAny(attachments))
193 return s.describeImagePartsWithAuxiliaryVision(ctx, req, primarySupportsVision, images)
194}
195
196func (s *Service) describeImagePartsWithAuxiliaryVision(The generated description is appended as an <auxiliary_vision_description> block at the tail of the last user message, once again leaving the cached prefix untouched:
454func appendAuxiliaryVisionToLastUserMessage(messages []sdk.Message, visionContext string) []sdk.Message {
455 visionContext = strings.TrimSpace(visionContext)
456 if visionContext == "" {
457 return messages
458 }
459 for i := len(messages) - 1; i >= 0; i-- {
460 if messages[i].Role != sdk.MessageRoleUser {
461 continue
462 }
463 messages[i].Content = append(messages[i].Content, sdk.TextPart{Text: "\n\n" + visionContext})
464 return messages
465 }
466 return append(messages, sdk.UserMessage(visionContext))
467}Bonus Telegram-Specific Polish
A few additional improvements aimed at making the bot feel more natural in Telegram group chats also helped keep costs predictable:
- Continuously refreshing the
typingindicator throughout any turn where the bot is selected to reply, so group members see "is typing…" instead of 30 seconds of dead silence followed by a wall of text; - Switching context compaction to a rolling-summary mode whose prompt explicitly forbids repeating raw tool-argument JSON; the context budget takes the smaller of the model's context window and the bot's compaction threshold, and truncation boundaries never split a
user/toolpair mid-exchange.
How Much Did It Actually Save?
Modeling the savings is straightforward. The input cost of a single LLM call is:
where T is the number of input tokens, h is the cache hit rate, and r = P_cache / P_in is the ratio of cached-input price to uncached-input price. Multiplying by the number of calls N per turn separates the before/after ratio into three independent factors:
where . Each of the three factors is grounded in real measurements:
- N_old / N_new = 2–3: pure text turns drop from 2 calls to 1, and reply + sticker turns drop from 3 calls to 1. This is structural—no estimation involved;
- T_old / T_new ≈ 70K / 52K ≈ 1.35: stripping the unused tool schemas (49.5 KB), dynamic sticker catalog (18.3 KB), and repeated message headers removes ~20K tokens per request;
- h_new = 99.7% is measured in production; I set
h_old = 95%to be generous to upstream's automatic prefix caching, which works well on ordinary turns and only loses its prefix whenMEMORY.mdis rewritten.
Using official provider pricing as of 2026-08-03—DeepSeek V4 Flash at $0.0028/$0.14 = 1/50, Claude Sonnet 4.6 at 1/10, Kimi K2.6 at ~1/6, and Doubao seed-1.6 at 1/5—and plugging in conservative numbers (N ratio = 2, h_old = 95%):
| Model | r | f(95%) / f(99.7%) | Total Factor 2 × 1.35 × f-ratio | Savings |
|---|---|---|---|---|
| DeepSeek V4 Flash | 1/50 | 3.0 | ≈ 8.1× | ≈ 88% |
| Claude Sonnet 4.6 | 1/10 | 1.4 | ≈ 3.8× | ≈ 74% |
| Kimi K2.6 | ≈1/6 | 1.2 | ≈ 3.3× | ≈ 70% |
The pattern is obvious: the steeper the cache discount, the more leverage you get from pushing the hit rate to 99.7%—DeepSeek's 50× cache discount pushes total savings close to 90%. Meanwhile, the call-count factor N doesn't depend on caching at all; it's free money. This matches what I see on my actual billing dashboard: costs dropped to well under 1/5 of what they were, and the conservative formula gives ~1/8 on DeepSeek even before counting the eliminated output tokens.
Closing Thoughts
All I really did was ask one question at every step of the pipeline: when you put a general-purpose agent framework into a high-frequency group chat, is this read or this LLM call actually necessary? Don't make an extra call just to confirm a sent message, combine text + reply + sticker into a single call, push volatile files to the tail of the prompt prefix, don't send a single byte of unused tool schemas, and let a cheap small vision model look at images first.
Once again, credit goes to upstream for building a solid foundation: the tool loop, history projection, and channel abstractions are cleanly separated. The reason I could pull off this degree of specialization in a little over a week is precisely because those boundaries were well drawn—wrapping the provider for terminal send without touching the core loop, and filtering tools before schema generation, both plugged cleanly into seams the architecture already had. If your use case is "an agent that does multi-step work," upstream's defaults are probably right for you; if, like me, you just want a talkative resident in your Telegram group that won't burn through your wallet, I hope these notes help.